Skip to main content

reinhardt_testkit/
client.rs

1//! API Client for testing
2//!
3//! Similar to DRF's APIClient, provides methods for making test requests
4//! with authentication, cookies, and headers support.
5
6use bytes::Bytes;
7use http::{HeaderMap, HeaderValue, Method, Request, Response};
8use http_body_util::{BodyExt, Full};
9use serde::Serialize;
10use serde_json::Value;
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::Duration;
14use thiserror::Error;
15use tokio::sync::RwLock;
16
17use reinhardt_di::InjectionContext;
18use reinhardt_http::{Handler as HttpHandler, Request as HttpRequest, Response as HttpResponse};
19
20use crate::response::TestResponse;
21
22/// HTTP version configuration for APIClient
23#[derive(Debug, Clone, Copy, Default)]
24pub enum HttpVersion {
25	/// Use HTTP/1.1 only
26	Http1Only,
27	/// Use HTTP/2 with prior knowledge (no upgrade negotiation)
28	Http2PriorKnowledge,
29	/// Auto-negotiate (default)
30	#[default]
31	Auto,
32}
33
34/// Errors that can occur when using the API test client.
35#[derive(Debug, Error)]
36pub enum ClientError {
37	/// HTTP protocol error.
38	#[error("HTTP error: {0}")]
39	Http(#[from] http::Error),
40
41	/// Hyper transport error.
42	#[error("Hyper error: {0}")]
43	Hyper(#[from] hyper::Error),
44
45	/// JSON serialization/deserialization error.
46	#[error("Serialization error: {0}")]
47	Serialization(#[from] serde_json::Error),
48
49	/// Invalid HTTP header value.
50	#[error("Invalid header value: {0}")]
51	InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
52
53	/// Reqwest HTTP client error.
54	#[error("Reqwest error: {0}")]
55	Reqwest(#[from] reqwest::Error),
56
57	/// General request failure.
58	#[error("Request failed: {0}")]
59	RequestFailed(String),
60}
61
62impl ClientError {
63	/// Returns true if the error is a timeout error
64	pub fn is_timeout(&self) -> bool {
65		match self {
66			ClientError::Reqwest(e) => e.is_timeout(),
67			_ => false,
68		}
69	}
70
71	/// Returns true if the error is a connection error
72	pub fn is_connect(&self) -> bool {
73		match self {
74			ClientError::Reqwest(e) => e.is_connect(),
75			_ => false,
76		}
77	}
78
79	/// Returns true if the error occurred during request building
80	pub fn is_request(&self) -> bool {
81		match self {
82			ClientError::Reqwest(e) => e.is_request(),
83			ClientError::Http(_) => true,
84			ClientError::InvalidHeaderValue(_) => true,
85			ClientError::Serialization(_) => true,
86			ClientError::RequestFailed(_) => true,
87			_ => false,
88		}
89	}
90}
91
92/// Result type for API client operations.
93pub type ClientResult<T> = Result<T, ClientError>;
94
95/// Type alias for request handler function
96pub type RequestHandler = Arc<dyn Fn(Request<Full<Bytes>>) -> Response<Full<Bytes>> + Send + Sync>;
97
98/// Builder for creating APIClient with custom configuration
99///
100/// # Example
101/// ```rust,no_run
102/// use reinhardt_testkit::client::{APIClientBuilder, HttpVersion};
103/// use std::time::Duration;
104///
105/// let client = APIClientBuilder::new()
106///     .base_url("http://localhost:8080")
107///     .timeout(Duration::from_secs(30))
108///     .http_version(HttpVersion::Http2PriorKnowledge)
109///     .cookie_store(true)
110///     .build();
111/// ```
112pub struct APIClientBuilder {
113	base_url: String,
114	timeout: Option<Duration>,
115	http_version: HttpVersion,
116	cookie_store: bool,
117	framework_handler: Option<Arc<dyn HttpHandler>>,
118	di_context: Option<Arc<InjectionContext>>,
119}
120
121impl APIClientBuilder {
122	/// Create a new builder with default configuration
123	pub fn new() -> Self {
124		Self {
125			base_url: "http://testserver".to_string(),
126			timeout: None,
127			http_version: HttpVersion::Auto,
128			cookie_store: false,
129			framework_handler: None,
130			di_context: None,
131		}
132	}
133
134	/// Set the base URL for requests
135	pub fn base_url(mut self, url: impl Into<String>) -> Self {
136		self.base_url = url.into();
137		self
138	}
139
140	/// Set the request timeout
141	pub fn timeout(mut self, duration: Duration) -> Self {
142		self.timeout = Some(duration);
143		self
144	}
145
146	/// Set the HTTP version
147	pub fn http_version(mut self, version: HttpVersion) -> Self {
148		self.http_version = version;
149		self
150	}
151
152	/// Use HTTP/1.1 only (convenience method)
153	pub fn http1_only(mut self) -> Self {
154		self.http_version = HttpVersion::Http1Only;
155		self
156	}
157
158	/// Use HTTP/2 with prior knowledge (convenience method)
159	pub fn http2_prior_knowledge(mut self) -> Self {
160		self.http_version = HttpVersion::Http2PriorKnowledge;
161		self
162	}
163
164	/// Enable or disable automatic cookie storage
165	pub fn cookie_store(mut self, enabled: bool) -> Self {
166		self.cookie_store = enabled;
167		self
168	}
169
170	/// Set a reinhardt `Handler` for in-process request dispatching.
171	///
172	/// When set, requests bypass the network and are handled directly
173	/// by the given Handler, running the full middleware stack in-process.
174	///
175	/// The calling test must run inside a tokio runtime (e.g., `#[tokio::test]`).
176	pub fn handler(mut self, handler: impl HttpHandler + 'static) -> Self {
177		self.framework_handler = Some(Arc::new(handler));
178		self
179	}
180
181	/// Set a DI context for in-process handler requests.
182	///
183	/// The context is injected into every reinhardt `Request` before
184	/// dispatching to the Handler.
185	pub fn di_context(mut self, ctx: Arc<InjectionContext>) -> Self {
186		self.di_context = Some(ctx);
187		self
188	}
189
190	/// Build the APIClient
191	pub fn build(self) -> APIClient {
192		let mut client_builder = reqwest::Client::builder();
193
194		// Configure timeout
195		if let Some(timeout) = self.timeout {
196			client_builder = client_builder.timeout(timeout);
197		}
198
199		// Configure HTTP version
200		match self.http_version {
201			HttpVersion::Http1Only => {
202				client_builder = client_builder.http1_only();
203			}
204			HttpVersion::Http2PriorKnowledge => {
205				client_builder = client_builder.http2_prior_knowledge();
206			}
207			HttpVersion::Auto => {
208				// Default behavior, no special configuration needed
209			}
210		}
211
212		// Configure cookie store
213		if self.cookie_store {
214			client_builder = client_builder.cookie_store(true);
215		}
216
217		let http_client = client_builder
218			.build()
219			.expect("Failed to build reqwest client");
220
221		let mut client = APIClient {
222			base_url: self.base_url,
223			default_headers: Arc::new(RwLock::new(HeaderMap::new())),
224			cookies: Arc::new(RwLock::new(HashMap::new())),
225			user: Arc::new(RwLock::new(None)),
226			handler: None,
227			async_handler: None,
228			handler_di_context: None,
229			http_client,
230			use_cookie_store: self.cookie_store,
231		};
232
233		// Wire up framework Handler for in-process dispatch
234		if let Some(fw_handler) = self.framework_handler {
235			client.async_handler = Some(fw_handler);
236			client.handler_di_context = self.di_context;
237
238			// Set default Origin header for OriginGuardMiddleware compatibility
239			if let Ok(mut headers) = client.default_headers.try_write()
240				&& let Ok(origin) = HeaderValue::from_str(&client.base_url)
241			{
242				headers.insert(http::header::ORIGIN, origin);
243			}
244		}
245
246		client
247	}
248}
249
250impl Default for APIClientBuilder {
251	fn default() -> Self {
252		Self::new()
253	}
254}
255
256/// Test client for making API requests
257///
258/// # Example
259/// ```rust,no_run
260/// use reinhardt_testkit::APIClient;
261/// use http::StatusCode;
262/// use serde_json::json;
263///
264/// # #[tokio::main]
265/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
266/// let client = APIClient::with_base_url("http://localhost:8080");
267/// let credentials = json!({"username": "user", "password": "pass"});
268/// client.post("/auth/login", &credentials, "json").await?;
269/// let response = client.get("/api/users/").await?;
270/// assert_eq!(response.status(), StatusCode::OK);
271/// # Ok(())
272/// # }
273/// ```
274pub struct APIClient {
275	/// Base URL for requests (e.g., "http://testserver")
276	base_url: String,
277
278	/// Default headers to include in all requests
279	default_headers: Arc<RwLock<HeaderMap>>,
280
281	/// Cookies to include in requests (manual management)
282	cookies: Arc<RwLock<HashMap<String, String>>>,
283
284	/// Current authenticated user (if any)
285	user: Arc<RwLock<Option<Value>>>,
286
287	/// Handler function for processing requests (sync, for set_handler)
288	handler: Option<RequestHandler>,
289
290	/// In-process async handler for framework Handler trait dispatch
291	async_handler: Option<Arc<dyn HttpHandler>>,
292
293	/// DI context injected into requests when using async_handler
294	handler_di_context: Option<Arc<InjectionContext>>,
295
296	/// Reusable HTTP client with connection pooling
297	http_client: reqwest::Client,
298
299	/// Whether automatic cookie storage is enabled
300	use_cookie_store: bool,
301}
302
303impl APIClient {
304	/// Create a new API client
305	///
306	/// # Examples
307	///
308	/// ```
309	/// use reinhardt_testkit::client::APIClient;
310	///
311	/// let client = APIClient::new();
312	/// assert_eq!(client.base_url(), "http://testserver");
313	/// ```
314	pub fn new() -> Self {
315		APIClientBuilder::new().build()
316	}
317
318	/// Create a client with a custom base URL
319	///
320	/// # Examples
321	///
322	/// ```
323	/// use reinhardt_testkit::client::APIClient;
324	///
325	/// let client = APIClient::with_base_url("https://api.example.com");
326	/// assert_eq!(client.base_url(), "https://api.example.com");
327	/// ```
328	pub fn with_base_url(base_url: impl Into<String>) -> Self {
329		APIClientBuilder::new().base_url(base_url).build()
330	}
331
332	/// Create a test client that dispatches requests directly to a
333	/// reinhardt `Handler` without TCP.
334	///
335	/// The Handler runs the full middleware stack in-process.
336	/// Sets `base_url` to `"http://testserver"` and injects a default
337	/// `Origin` header for `OriginGuardMiddleware` compatibility.
338	///
339	/// # Panics
340	///
341	/// Panics if called outside a tokio runtime.
342	///
343	/// # Examples
344	///
345	/// ```rust,no_run
346	/// use reinhardt_testkit::APIClient;
347	///
348	/// // let router = build_routes(scope).into_server();
349	/// // let client = APIClient::from_handler(router);
350	/// // let resp = client.get("/api/health/").await.unwrap();
351	/// ```
352	pub fn from_handler(handler: impl HttpHandler + 'static) -> Self {
353		APIClientBuilder::new().handler(handler).build()
354	}
355
356	/// Create a builder for customizing the client configuration
357	///
358	/// # Examples
359	///
360	/// ```
361	/// use reinhardt_testkit::client::APIClient;
362	/// use std::time::Duration;
363	///
364	/// let client = APIClient::builder()
365	///     .base_url("http://localhost:8080")
366	///     .timeout(Duration::from_secs(30))
367	///     .build();
368	/// ```
369	pub fn builder() -> APIClientBuilder {
370		APIClientBuilder::new()
371	}
372	/// Get the base URL of this client.
373	pub fn base_url(&self) -> &str {
374		&self.base_url
375	}
376	/// Set a request handler for testing
377	///
378	/// # Examples
379	///
380	/// ```
381	/// use reinhardt_testkit::client::APIClient;
382	/// use http::{Request, Response, StatusCode};
383	/// use http_body_util::Full;
384	/// use bytes::Bytes;
385	///
386	/// let mut client = APIClient::new();
387	/// client.set_handler(|_req| {
388	///     Response::builder()
389	///         .status(StatusCode::OK)
390	///         .body(Full::new(Bytes::from("test")))
391	///         .unwrap()
392	/// });
393	/// ```
394	pub fn set_handler<F>(&mut self, handler: F)
395	where
396		F: Fn(Request<Full<Bytes>>) -> Response<Full<Bytes>> + Send + Sync + 'static,
397	{
398		self.handler = Some(Arc::new(handler));
399	}
400	/// Set a default header for all requests
401	///
402	/// # Examples
403	///
404	/// ```
405	/// use reinhardt_testkit::client::APIClient;
406	///
407	/// # tokio_test::block_on(async {
408	/// let client = APIClient::new();
409	/// client.set_header("User-Agent", "TestClient/1.0").await.unwrap();
410	/// # });
411	/// ```
412	pub async fn set_header(
413		&self,
414		name: impl AsRef<str>,
415		value: impl AsRef<str>,
416	) -> ClientResult<()> {
417		let mut headers = self.default_headers.write().await;
418		let header_name: http::header::HeaderName = name.as_ref().parse().map_err(|_| {
419			ClientError::RequestFailed(format!("Invalid header name: {}", name.as_ref()))
420		})?;
421		headers.insert(header_name, HeaderValue::from_str(value.as_ref())?);
422		Ok(())
423	}
424	/// Set credentials for Basic Authentication
425	///
426	/// # Examples
427	///
428	/// ```
429	/// use reinhardt_testkit::client::APIClient;
430	///
431	/// # tokio_test::block_on(async {
432	/// let client = APIClient::new();
433	/// client.credentials("username", "password").await.unwrap();
434	/// # });
435	/// ```
436	pub async fn credentials(&self, username: &str, password: &str) -> ClientResult<()> {
437		let encoded = base64::encode(format!("{}:{}", username, password));
438		self.set_header("Authorization", format!("Basic {}", encoded))
439			.await
440	}
441	/// Clear authentication and cookies
442	///
443	/// # Examples
444	///
445	/// ```
446	/// use reinhardt_testkit::client::APIClient;
447	///
448	/// # tokio_test::block_on(async {
449	/// let client = APIClient::new();
450	/// client.clear_auth().await.unwrap();
451	/// # });
452	/// ```
453	pub async fn clear_auth(&self) -> ClientResult<()> {
454		{
455			let mut current_user = self.user.write().await;
456			*current_user = None;
457		}
458		let mut cookies = self.cookies.write().await;
459		cookies.clear();
460		drop(cookies);
461		// Clear auth-related headers (Authorization, X-MFA-Code, X-Test-User)
462		let mut headers = self.default_headers.write().await;
463		headers.remove("authorization");
464		headers.remove("x-mfa-code");
465		headers.remove("x-test-user");
466		Ok(())
467	}
468
469	/// Set a cookie that will be sent with subsequent requests.
470	///
471	/// # Panics
472	///
473	/// Panics if `name` contains `=` or `;`, or if `value` contains `;`.
474	pub async fn set_cookie(&self, name: &str, value: &str) -> ClientResult<()> {
475		validate_cookie_key(name);
476		validate_cookie_value(value);
477		let mut cookies = self.cookies.write().await;
478		cookies.insert(name.to_string(), value.to_string());
479		Ok(())
480	}
481
482	/// Remove a specific cookie.
483	pub async fn remove_cookie(&self, name: &str) -> ClientResult<()> {
484		let mut cookies = self.cookies.write().await;
485		cookies.remove(name);
486		Ok(())
487	}
488
489	/// Clear all authentication state (session cookies, auth headers, stored user).
490	///
491	/// Clears the current authenticated user and all related authentication state.
492	pub async fn logout(&self) -> ClientResult<()> {
493		self.clear_auth().await
494	}
495
496	/// Start building an auth configuration for this client.
497	///
498	/// # Examples
499	///
500	/// ```rust,ignore
501	/// client.auth()
502	///     .session(&user, &session_store)
503	///     .with_staff(true)
504	///     .apply().await?;
505	/// ```
506	#[cfg(native)]
507	pub fn auth(&self) -> crate::auth::AuthBuilder<'_> {
508		crate::auth::AuthBuilder::new(self)
509	}
510
511	/// Clean up all client state for teardown
512	///
513	/// This method performs a complete cleanup of the client state including:
514	/// - Clearing authentication
515	/// - Clearing cookies
516	/// - Clearing default headers
517	///
518	/// This is typically called during test teardown to ensure clean state
519	/// between tests.
520	///
521	/// # Examples
522	///
523	/// ```
524	/// use reinhardt_testkit::client::APIClient;
525	///
526	/// # tokio_test::block_on(async {
527	/// let client = APIClient::new();
528	/// client.set_header("X-Custom", "value").await.unwrap();
529	/// client.cleanup().await;
530	/// // All state is now cleared
531	/// # });
532	/// ```
533	pub async fn cleanup(&self) {
534		// Clear authentication
535		{
536			let mut current_user = self.user.write().await;
537			*current_user = None;
538		}
539
540		// Clear cookies
541		{
542			let mut cookies = self.cookies.write().await;
543			cookies.clear();
544		}
545
546		// Clear default headers
547		{
548			let mut headers = self.default_headers.write().await;
549			headers.clear();
550		}
551	}
552	/// Make a GET request
553	///
554	/// # Examples
555	///
556	/// ```
557	/// use reinhardt_testkit::client::APIClient;
558	///
559	/// # tokio_test::block_on(async {
560	/// let client = APIClient::new();
561	// Note: get() requires a working handler
562	// let response = client.get("/api/users/").await;
563	/// # });
564	/// ```
565	pub async fn get(&self, path: &str) -> ClientResult<TestResponse> {
566		self.request(Method::GET, path, None, None).await
567	}
568	/// Make a POST request
569	///
570	/// # Examples
571	///
572	/// ```
573	/// use reinhardt_testkit::client::APIClient;
574	/// use serde_json::json;
575	///
576	/// # tokio_test::block_on(async {
577	/// let client = APIClient::new();
578	/// let data = json!({"name": "test"});
579	// Note: post() requires a working handler
580	// let response = client.post("/api/users/", &data, "json").await;
581	/// # });
582	/// ```
583	pub async fn post<T: Serialize>(
584		&self,
585		path: &str,
586		data: &T,
587		format: &str,
588	) -> ClientResult<TestResponse> {
589		let body = self.serialize_data(data, format)?;
590		let content_type = self.get_content_type(format);
591		self.request(Method::POST, path, Some(body), Some(content_type))
592			.await
593	}
594	/// Make a PUT request
595	///
596	/// # Examples
597	///
598	/// ```
599	/// use reinhardt_testkit::client::APIClient;
600	/// use serde_json::json;
601	///
602	/// # tokio_test::block_on(async {
603	/// let client = APIClient::new();
604	/// let data = json!({"name": "updated"});
605	// Note: put() requires a working handler
606	// let response = client.put("/api/users/1/", &data, "json").await;
607	/// # });
608	/// ```
609	pub async fn put<T: Serialize>(
610		&self,
611		path: &str,
612		data: &T,
613		format: &str,
614	) -> ClientResult<TestResponse> {
615		let body = self.serialize_data(data, format)?;
616		let content_type = self.get_content_type(format);
617		self.request(Method::PUT, path, Some(body), Some(content_type))
618			.await
619	}
620	/// Make a PATCH request
621	///
622	/// # Examples
623	///
624	/// ```
625	/// use reinhardt_testkit::client::APIClient;
626	/// use serde_json::json;
627	///
628	/// # tokio_test::block_on(async {
629	/// let client = APIClient::new();
630	/// let data = json!({"name": "partial_update"});
631	// Note: patch() requires a working handler
632	// let response = client.patch("/api/users/1/", &data, "json").await;
633	/// # });
634	/// ```
635	pub async fn patch<T: Serialize>(
636		&self,
637		path: &str,
638		data: &T,
639		format: &str,
640	) -> ClientResult<TestResponse> {
641		let body = self.serialize_data(data, format)?;
642		let content_type = self.get_content_type(format);
643		self.request(Method::PATCH, path, Some(body), Some(content_type))
644			.await
645	}
646	/// Make a DELETE request
647	///
648	/// # Examples
649	///
650	/// ```
651	/// use reinhardt_testkit::client::APIClient;
652	///
653	/// # tokio_test::block_on(async {
654	/// let client = APIClient::new();
655	// Note: delete() requires a working handler
656	// let response = client.delete("/api/users/1/").await;
657	/// # });
658	/// ```
659	pub async fn delete(&self, path: &str) -> ClientResult<TestResponse> {
660		self.request(Method::DELETE, path, None, None).await
661	}
662	/// Make a HEAD request
663	///
664	/// # Examples
665	///
666	/// ```
667	/// use reinhardt_testkit::client::APIClient;
668	///
669	/// # tokio_test::block_on(async {
670	/// let client = APIClient::new();
671	// Note: head() requires a working handler
672	// let response = client.head("/api/users/").await;
673	/// # });
674	/// ```
675	pub async fn head(&self, path: &str) -> ClientResult<TestResponse> {
676		self.request(Method::HEAD, path, None, None).await
677	}
678	/// Make an OPTIONS request
679	///
680	/// # Examples
681	///
682	/// ```
683	/// use reinhardt_testkit::client::APIClient;
684	///
685	/// # tokio_test::block_on(async {
686	/// let client = APIClient::new();
687	// Note: options() requires a working handler
688	// let response = client.options("/api/users/").await;
689	/// # });
690	/// ```
691	pub async fn options(&self, path: &str) -> ClientResult<TestResponse> {
692		self.request(Method::OPTIONS, path, None, None).await
693	}
694
695	/// Make a GET request with additional per-request headers
696	///
697	/// # Examples
698	///
699	/// ```
700	/// use reinhardt_testkit::client::APIClient;
701	///
702	/// # tokio_test::block_on(async {
703	/// let client = APIClient::with_base_url("http://localhost:8080");
704	/// // let response = client.get_with_headers("/api/data", &[("Accept", "application/json")]).await;
705	/// # });
706	/// ```
707	pub async fn get_with_headers(
708		&self,
709		path: &str,
710		headers: &[(&str, &str)],
711	) -> ClientResult<TestResponse> {
712		self.request_with_extra_headers(Method::GET, path, None, None, headers)
713			.await
714	}
715
716	/// Make a POST request with raw body and additional per-request headers
717	///
718	/// Unlike `post()`, this method allows setting a raw body without automatic serialization.
719	///
720	/// # Examples
721	///
722	/// ```
723	/// use reinhardt_testkit::client::APIClient;
724	///
725	/// # tokio_test::block_on(async {
726	/// let client = APIClient::with_base_url("http://localhost:8080");
727	/// // let response = client.post_raw_with_headers(
728	/// //     "/api/echo",
729	/// //     b"{\"test\":\"data\"}",
730	/// //     "application/json",
731	/// //     &[("X-Custom-Header", "value")]
732	/// // ).await;
733	/// # });
734	/// ```
735	pub async fn post_raw_with_headers(
736		&self,
737		path: &str,
738		body: &[u8],
739		content_type: &str,
740		headers: &[(&str, &str)],
741	) -> ClientResult<TestResponse> {
742		self.request_with_extra_headers(
743			Method::POST,
744			path,
745			Some(Bytes::copy_from_slice(body)),
746			Some(content_type),
747			headers,
748		)
749		.await
750	}
751
752	/// Make a POST request with raw body
753	///
754	/// Unlike `post()`, this method allows setting a raw body without automatic serialization.
755	///
756	/// # Examples
757	///
758	/// ```
759	/// use reinhardt_testkit::client::APIClient;
760	///
761	/// # tokio_test::block_on(async {
762	/// let client = APIClient::with_base_url("http://localhost:8080");
763	/// // let response = client.post_raw("/api/echo", b"{\"test\":\"data\"}", "application/json").await;
764	/// # });
765	/// ```
766	pub async fn post_raw(
767		&self,
768		path: &str,
769		body: &[u8],
770		content_type: &str,
771	) -> ClientResult<TestResponse> {
772		self.request(
773			Method::POST,
774			path,
775			Some(Bytes::copy_from_slice(body)),
776			Some(content_type),
777		)
778		.await
779	}
780
781	/// Generic request method
782	async fn request(
783		&self,
784		method: Method,
785		path: &str,
786		body: Option<Bytes>,
787		content_type: Option<&str>,
788	) -> ClientResult<TestResponse> {
789		self.request_with_extra_headers(method, path, body, content_type, &[])
790			.await
791	}
792
793	/// Generic request method with additional per-request headers
794	///
795	/// This method is similar to `request()` but allows adding extra headers
796	/// that are specific to this request only, without modifying the default headers.
797	async fn request_with_extra_headers(
798		&self,
799		method: Method,
800		path: &str,
801		body: Option<Bytes>,
802		content_type: Option<&str>,
803		extra_headers: &[(&str, &str)],
804	) -> ClientResult<TestResponse> {
805		let url = if path.starts_with("http://") || path.starts_with("https://") {
806			path.to_string()
807		} else {
808			format!("{}{}", self.base_url, path)
809		};
810
811		let mut req_builder = Request::builder().method(method).uri(url);
812
813		// Add default headers
814		let default_headers = self.default_headers.read().await;
815		for (name, value) in default_headers.iter() {
816			req_builder = req_builder.header(name, value);
817		}
818
819		// Add extra per-request headers (these override default headers if same name)
820		for (name, value) in extra_headers {
821			req_builder = req_builder.header(*name, *value);
822		}
823
824		// Add content type if provided
825		if let Some(ct) = content_type {
826			req_builder = req_builder.header("Content-Type", ct);
827		}
828
829		// Add cookies (with validation to prevent header injection)
830		let cookies = self.cookies.read().await;
831		if !cookies.is_empty() {
832			let cookie_header = cookies
833				.iter()
834				.map(|(k, v)| {
835					validate_cookie_key(k);
836					validate_cookie_value(v);
837					format!("{}={}", k, v)
838				})
839				.collect::<Vec<_>>()
840				.join("; ");
841			req_builder = req_builder.header("Cookie", cookie_header);
842		}
843
844		// Add authentication if user is set
845		let user = self.user.read().await;
846		if user.is_some() {
847			// Add custom header to indicate forced authentication
848			req_builder = req_builder.header("X-Test-User", "authenticated");
849		}
850
851		// Build request with body
852		let request = if let Some(body_bytes) = body {
853			req_builder.body(Full::new(body_bytes))?
854		} else {
855			req_builder.body(Full::new(Bytes::new()))?
856		};
857
858		// Execute request
859		let response = if let Some(async_handler) = &self.async_handler {
860			// In-process dispatch via framework Handler trait
861			let (parts, body) = request.into_parts();
862			let body_bytes = body
863				.collect()
864				.await
865				.map(|c| c.to_bytes())
866				.unwrap_or_else(|_| Bytes::new());
867
868			let mut fw_request = HttpRequest::builder()
869				.method(parts.method)
870				.uri(parts.uri)
871				.version(parts.version)
872				.headers(parts.headers)
873				.body(body_bytes)
874				.build()
875				.expect("Failed to build reinhardt request");
876
877			if let Some(ctx) = &self.handler_di_context {
878				fw_request.set_di_context(Arc::clone(ctx));
879			}
880
881			let fw_response = async_handler
882				.handle(fw_request)
883				.await
884				.unwrap_or_else(HttpResponse::from);
885
886			let mut builder = http::Response::builder().status(fw_response.status);
887			for (key, value) in fw_response.headers.iter() {
888				builder = builder.header(key, value);
889			}
890			builder
891				.body(Full::new(fw_response.body))
892				.expect("Failed to build http::Response")
893		} else if let Some(handler) = &self.handler {
894			// Use custom sync handler if set
895			handler(request)
896		} else {
897			// Use reqwest for real HTTP requests when no handler is set
898			let (parts, body) = request.into_parts();
899
900			// Build reqwest request
901			let url = if parts.uri.scheme_str().is_some() {
902				// Absolute URL
903				parts.uri.to_string()
904			} else {
905				// Relative path - use base_url
906				format!(
907					"{}{}",
908					self.base_url.trim_end_matches('/'),
909					parts.uri.path()
910				)
911			};
912
913			// Use the stored http_client (connection pooling enabled)
914			let mut reqwest_request = self.http_client.request(
915				reqwest::Method::from_bytes(parts.method.as_str().as_bytes()).unwrap(),
916				&url,
917			);
918
919			// Copy headers (skip Cookie if using cookie_store, as reqwest manages it automatically)
920			for (name, value) in parts.headers.iter() {
921				if self.use_cookie_store && name.as_str().eq_ignore_ascii_case("cookie") {
922					continue;
923				}
924				reqwest_request = reqwest_request.header(name.as_str(), value.as_bytes());
925			}
926
927			// Copy body
928			let body_bytes = body
929				.collect()
930				.await
931				.map(|c| c.to_bytes())
932				.unwrap_or_else(|_| Bytes::new());
933			if !body_bytes.is_empty() {
934				reqwest_request = reqwest_request.body(body_bytes.to_vec());
935			}
936
937			// Execute reqwest request
938			let reqwest_response = reqwest_request.send().await?;
939
940			// Convert reqwest response to http::Response
941			let status = reqwest_response.status();
942			let version = reqwest_response.version();
943			let headers = reqwest_response.headers().clone();
944			let body_bytes = reqwest_response.bytes().await?;
945
946			let mut response_builder = Response::builder().status(status).version(version);
947			for (name, value) in headers.iter() {
948				response_builder = response_builder.header(name, value);
949			}
950
951			response_builder.body(Full::new(body_bytes))?
952		};
953
954		// Extract body from response using async collection
955		let (parts, response_body) = response.into_parts();
956		let body_data = response_body
957			.collect()
958			.await
959			.map(|collected| collected.to_bytes())
960			.unwrap_or_else(|_| Bytes::new());
961
962		Ok(TestResponse::with_body_and_version(
963			parts.status,
964			parts.headers,
965			body_data,
966			parts.version,
967		))
968	}
969
970	/// Serialize data based on format
971	fn serialize_data<T: Serialize>(&self, data: &T, format: &str) -> ClientResult<Bytes> {
972		match format {
973			"json" => {
974				let json = serde_json::to_vec(data)?;
975				Ok(Bytes::from(json))
976			}
977			"form" => {
978				// URL-encoded form data
979				let json_value = serde_json::to_value(data)?;
980				if let Value::Object(map) = json_value {
981					let form_data = map
982						.iter()
983						.map(|(k, v)| {
984							let value_str = match v {
985								Value::String(s) => s.clone(),
986								_ => v.to_string(),
987							};
988							format!(
989								"{}={}",
990								urlencoding::encode(k),
991								urlencoding::encode(&value_str)
992							)
993						})
994						.collect::<Vec<_>>()
995						.join("&");
996					Ok(Bytes::from(form_data))
997				} else {
998					Err(ClientError::RequestFailed(
999						"Expected object for form data".to_string(),
1000					))
1001				}
1002			}
1003			_ => Err(ClientError::RequestFailed(format!(
1004				"Unsupported format: {}",
1005				format
1006			))),
1007		}
1008	}
1009
1010	/// Get content type for format
1011	fn get_content_type(&self, format: &str) -> &str {
1012		match format {
1013			"json" => "application/json",
1014			"form" => "application/x-www-form-urlencoded",
1015			_ => "application/octet-stream",
1016		}
1017	}
1018}
1019
1020/// Validate a cookie key to prevent header injection attacks.
1021///
1022/// Cookie keys must not contain `=`, `;`, whitespace, or control characters.
1023///
1024/// # Panics
1025///
1026/// Panics if the cookie key contains invalid characters.
1027fn validate_cookie_key(key: &str) {
1028	assert!(!key.is_empty(), "cookie key must not be empty");
1029	assert!(
1030		!key.contains('='),
1031		"cookie key must not contain '=' (found in key: {:?})",
1032		key
1033	);
1034	assert!(
1035		!key.contains(';'),
1036		"cookie key must not contain ';' (found in key: {:?})",
1037		key
1038	);
1039	assert!(
1040		!key.chars().any(|c| c.is_ascii_whitespace()),
1041		"cookie key must not contain whitespace (found in key: {:?})",
1042		key
1043	);
1044	assert!(
1045		!key.chars().any(|c| c.is_control()),
1046		"cookie key must not contain control characters (found in key: {:?})",
1047		key
1048	);
1049}
1050
1051/// Validate a cookie value to prevent header injection attacks.
1052///
1053/// Cookie values must not contain `;`, newlines (`\r`, `\n`), or control characters.
1054///
1055/// # Panics
1056///
1057/// Panics if the cookie value contains invalid characters.
1058fn validate_cookie_value(value: &str) {
1059	assert!(!value.contains(';'), "cookie value must not contain ';'");
1060	assert!(
1061		!value.contains('\r') && !value.contains('\n'),
1062		"cookie value must not contain newlines"
1063	);
1064	assert!(
1065		!value.chars().any(|c| c.is_control()),
1066		"cookie value must not contain control characters"
1067	);
1068}
1069
1070impl Default for APIClient {
1071	fn default() -> Self {
1072		Self::new()
1073	}
1074}
1075
1076// Need to add base64 dependency
1077mod base64 {
1078	pub(super) fn encode(input: String) -> String {
1079		// Simple base64 encoding (in production, use a proper library)
1080		use base64_simd::STANDARD;
1081		STANDARD.encode_to_string(input.as_bytes())
1082	}
1083}
1084
1085// Need to add urlencoding
1086mod urlencoding {
1087	pub(super) fn encode(input: &str) -> String {
1088		url::form_urlencoded::byte_serialize(input.as_bytes()).collect()
1089	}
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094	use super::*;
1095	use async_trait::async_trait;
1096	use reinhardt_core::exception::{Error as HttpError, Result as HttpResult};
1097	use rstest::rstest;
1098
1099	/// Handler that echoes the request path in the response body
1100	/// and reflects request headers as X-Echo-* response headers.
1101	struct EchoHandler;
1102
1103	#[async_trait]
1104	impl HttpHandler for EchoHandler {
1105		async fn handle(&self, request: HttpRequest) -> HttpResult<HttpResponse> {
1106			let path = request.uri.path().to_string();
1107			let has_custom = request.headers.get("X-Custom").is_some();
1108			let content_type = request
1109				.headers
1110				.get("Content-Type")
1111				.and_then(|v| v.to_str().ok())
1112				.unwrap_or("")
1113				.to_string();
1114
1115			let mut response = HttpResponse::ok().with_body(path.clone());
1116			response = response.try_with_header("X-Echo-Path", &path)?;
1117
1118			if has_custom {
1119				response = response.try_with_header("X-Echo-Custom", "present")?;
1120			}
1121			if !content_type.is_empty() {
1122				response = response.try_with_header("X-Echo-Content-Type", &content_type)?;
1123			}
1124			Ok(response)
1125		}
1126	}
1127
1128	/// Handler that always returns an error.
1129	struct ErrorHandler;
1130
1131	#[async_trait]
1132	impl HttpHandler for ErrorHandler {
1133		async fn handle(&self, _request: HttpRequest) -> HttpResult<HttpResponse> {
1134			Err(HttpError::NotFound("test resource".to_string()))
1135		}
1136	}
1137
1138	#[rstest]
1139	#[tokio::test]
1140	async fn test_from_handler_basic() {
1141		// Arrange
1142		let client = APIClient::from_handler(EchoHandler);
1143
1144		// Act
1145		let response = client.get("/test/path/").await.expect("request failed");
1146
1147		// Assert
1148		assert_eq!(response.status(), http::StatusCode::OK);
1149		assert_eq!(response.body().as_ref(), b"/test/path/");
1150	}
1151
1152	#[rstest]
1153	#[tokio::test]
1154	async fn test_from_handler_post_body() {
1155		// Arrange
1156		let client = APIClient::from_handler(EchoHandler);
1157		let body = serde_json::json!({"key": "value"});
1158
1159		// Act
1160		let response = client
1161			.post("/echo/", &body, "json")
1162			.await
1163			.expect("request failed");
1164
1165		// Assert
1166		assert_eq!(response.status(), http::StatusCode::OK);
1167		assert_eq!(
1168			response
1169				.header("X-Echo-Content-Type")
1170				.expect("missing header"),
1171			"application/json"
1172		);
1173	}
1174
1175	#[rstest]
1176	#[tokio::test]
1177	async fn test_from_handler_headers() {
1178		// Arrange
1179		let client = APIClient::from_handler(EchoHandler);
1180		client
1181			.set_header("X-Custom", "test-value")
1182			.await
1183			.expect("set_header failed");
1184
1185		// Act
1186		let response = client.get("/test/").await.expect("request failed");
1187
1188		// Assert
1189		assert_eq!(response.status(), http::StatusCode::OK);
1190		assert_eq!(
1191			response.header("X-Echo-Custom").expect("missing header"),
1192			"present"
1193		);
1194	}
1195
1196	#[rstest]
1197	#[tokio::test]
1198	async fn test_from_handler_error_conversion() {
1199		// Arrange
1200		let client = APIClient::from_handler(ErrorHandler);
1201
1202		// Act
1203		let response = client.get("/anything/").await.expect("request failed");
1204
1205		// Assert
1206		assert_eq!(response.status(), http::StatusCode::NOT_FOUND);
1207	}
1208
1209	#[rstest]
1210	#[tokio::test]
1211	async fn test_from_handler_origin_header() {
1212		// Arrange
1213		let client = APIClient::from_handler(EchoHandler);
1214
1215		// Act
1216		let headers = client.default_headers.read().await;
1217
1218		// Assert
1219		let origin = headers
1220			.get(http::header::ORIGIN)
1221			.expect("Origin header not set");
1222		assert_eq!(origin.to_str().unwrap(), "http://testserver");
1223	}
1224
1225	#[rstest]
1226	#[tokio::test]
1227	async fn test_builder_with_handler() {
1228		// Arrange
1229		let client = APIClient::builder()
1230			.base_url("http://mytest")
1231			.handler(EchoHandler)
1232			.build();
1233
1234		// Act
1235		let response = client.get("/api/").await.expect("request failed");
1236
1237		// Assert
1238		assert_eq!(response.status(), http::StatusCode::OK);
1239		let headers = client.default_headers.read().await;
1240		let origin = headers
1241			.get(http::header::ORIGIN)
1242			.expect("Origin header not set");
1243		assert_eq!(origin.to_str().unwrap(), "http://mytest");
1244	}
1245
1246	#[rstest]
1247	fn test_validate_cookie_key_accepts_valid_key() {
1248		// Arrange
1249		let key = "session_id";
1250
1251		// Act & Assert (should not panic)
1252		validate_cookie_key(key);
1253	}
1254
1255	#[rstest]
1256	#[should_panic(expected = "must not be empty")]
1257	fn test_validate_cookie_key_rejects_empty() {
1258		// Arrange
1259		let key = "";
1260
1261		// Act
1262		validate_cookie_key(key);
1263	}
1264
1265	#[rstest]
1266	#[should_panic(expected = "must not contain '='")]
1267	fn test_validate_cookie_key_rejects_equals_sign() {
1268		// Arrange
1269		let key = "key=value";
1270
1271		// Act
1272		validate_cookie_key(key);
1273	}
1274
1275	#[rstest]
1276	#[should_panic(expected = "must not contain ';'")]
1277	fn test_validate_cookie_key_rejects_semicolon() {
1278		// Arrange
1279		let key = "key;injection";
1280
1281		// Act
1282		validate_cookie_key(key);
1283	}
1284
1285	#[rstest]
1286	#[should_panic(expected = "must not contain whitespace")]
1287	fn test_validate_cookie_key_rejects_whitespace() {
1288		// Arrange
1289		let key = "key name";
1290
1291		// Act
1292		validate_cookie_key(key);
1293	}
1294
1295	#[rstest]
1296	#[should_panic(expected = "must not contain control characters")]
1297	fn test_validate_cookie_key_rejects_control_chars() {
1298		// Arrange
1299		let key = "key\x00name";
1300
1301		// Act
1302		validate_cookie_key(key);
1303	}
1304
1305	#[rstest]
1306	fn test_validate_cookie_value_accepts_valid_value() {
1307		// Arrange
1308		let value = "abc123-token";
1309
1310		// Act & Assert (should not panic)
1311		validate_cookie_value(value);
1312	}
1313
1314	#[rstest]
1315	fn test_validate_cookie_value_accepts_empty() {
1316		// Arrange
1317		let value = "";
1318
1319		// Act & Assert (should not panic)
1320		validate_cookie_value(value);
1321	}
1322
1323	#[rstest]
1324	#[should_panic(expected = "must not contain ';'")]
1325	fn test_validate_cookie_value_rejects_semicolon() {
1326		// Arrange
1327		let value = "value; extra=injected";
1328
1329		// Act
1330		validate_cookie_value(value);
1331	}
1332
1333	#[rstest]
1334	#[should_panic(expected = "must not contain newlines")]
1335	fn test_validate_cookie_value_rejects_newline() {
1336		// Arrange
1337		let value = "value\r\nInjected-Header: malicious";
1338
1339		// Act
1340		validate_cookie_value(value);
1341	}
1342
1343	#[rstest]
1344	#[should_panic(expected = "must not contain control characters")]
1345	fn test_validate_cookie_value_rejects_control_chars() {
1346		// Arrange
1347		let value = "value\x01hidden";
1348
1349		// Act
1350		validate_cookie_value(value);
1351	}
1352
1353	#[rstest]
1354	#[should_panic(expected = "must not contain newlines")]
1355	fn test_validate_cookie_value_rejects_lf_only() {
1356		// Arrange
1357		let value = "value\nInjected-Header: evil";
1358
1359		// Act
1360		validate_cookie_value(value);
1361	}
1362}