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	/// Force authenticate as a user (for testing)
425	///
426	/// # Examples
427	///
428	/// ```
429	/// use reinhardt_testkit::client::APIClient;
430	/// use serde_json::json;
431	///
432	/// # tokio_test::block_on(async {
433	/// let client = APIClient::new();
434	/// let user = json!({"id": 1, "username": "testuser"});
435	/// client.force_authenticate(Some(user)).await;
436	/// # });
437	/// ```
438	#[deprecated(
439		since = "0.1.0-rc.16",
440		note = "use `client.auth().session()` or `client.auth().jwt()` instead"
441	)]
442	pub async fn force_authenticate(&self, user: Option<Value>) {
443		let mut current_user = self.user.write().await;
444		*current_user = user;
445	}
446	/// Set credentials for Basic Authentication
447	///
448	/// # Examples
449	///
450	/// ```
451	/// use reinhardt_testkit::client::APIClient;
452	///
453	/// # tokio_test::block_on(async {
454	/// let client = APIClient::new();
455	/// client.credentials("username", "password").await.unwrap();
456	/// # });
457	/// ```
458	pub async fn credentials(&self, username: &str, password: &str) -> ClientResult<()> {
459		let encoded = base64::encode(format!("{}:{}", username, password));
460		self.set_header("Authorization", format!("Basic {}", encoded))
461			.await
462	}
463	/// Clear authentication and cookies
464	///
465	/// # Examples
466	///
467	/// ```
468	/// use reinhardt_testkit::client::APIClient;
469	///
470	/// # tokio_test::block_on(async {
471	/// let client = APIClient::new();
472	/// client.clear_auth().await.unwrap();
473	/// # });
474	/// ```
475	pub async fn clear_auth(&self) -> ClientResult<()> {
476		#[allow(deprecated)]
477		self.force_authenticate(None).await;
478		let mut cookies = self.cookies.write().await;
479		cookies.clear();
480		drop(cookies);
481		// Clear auth-related headers (Authorization, X-MFA-Code, X-Test-User)
482		let mut headers = self.default_headers.write().await;
483		headers.remove("authorization");
484		headers.remove("x-mfa-code");
485		headers.remove("x-test-user");
486		Ok(())
487	}
488
489	/// Set a cookie that will be sent with subsequent requests.
490	///
491	/// # Panics
492	///
493	/// Panics if `name` contains `=` or `;`, or if `value` contains `;`.
494	pub async fn set_cookie(&self, name: &str, value: &str) -> ClientResult<()> {
495		validate_cookie_key(name);
496		validate_cookie_value(value);
497		let mut cookies = self.cookies.write().await;
498		cookies.insert(name.to_string(), value.to_string());
499		Ok(())
500	}
501
502	/// Remove a specific cookie.
503	pub async fn remove_cookie(&self, name: &str) -> ClientResult<()> {
504		let mut cookies = self.cookies.write().await;
505		cookies.remove(name);
506		Ok(())
507	}
508
509	/// Clear all authentication state (session cookies, auth headers, stored user).
510	///
511	/// This is the replacement for `force_authenticate(None)`.
512	pub async fn logout(&self) -> ClientResult<()> {
513		self.clear_auth().await
514	}
515
516	/// Start building an auth configuration for this client.
517	///
518	/// # Examples
519	///
520	/// ```rust,ignore
521	/// client.auth()
522	///     .session(&user, &session_store)
523	///     .with_staff(true)
524	///     .apply().await?;
525	/// ```
526	#[cfg(native)]
527	pub fn auth(&self) -> crate::auth::AuthBuilder<'_> {
528		crate::auth::AuthBuilder::new(self)
529	}
530
531	/// Clean up all client state for teardown
532	///
533	/// This method performs a complete cleanup of the client state including:
534	/// - Clearing authentication
535	/// - Clearing cookies
536	/// - Clearing default headers
537	///
538	/// This is typically called during test teardown to ensure clean state
539	/// between tests.
540	///
541	/// # Examples
542	///
543	/// ```
544	/// use reinhardt_testkit::client::APIClient;
545	///
546	/// # tokio_test::block_on(async {
547	/// let client = APIClient::new();
548	/// client.set_header("X-Custom", "value").await.unwrap();
549	/// client.cleanup().await;
550	/// // All state is now cleared
551	/// # });
552	/// ```
553	pub async fn cleanup(&self) {
554		// Clear authentication
555		#[allow(deprecated)]
556		self.force_authenticate(None).await;
557
558		// Clear cookies
559		{
560			let mut cookies = self.cookies.write().await;
561			cookies.clear();
562		}
563
564		// Clear default headers
565		{
566			let mut headers = self.default_headers.write().await;
567			headers.clear();
568		}
569	}
570	/// Make a GET request
571	///
572	/// # Examples
573	///
574	/// ```
575	/// use reinhardt_testkit::client::APIClient;
576	///
577	/// # tokio_test::block_on(async {
578	/// let client = APIClient::new();
579	// Note: get() requires a working handler
580	// let response = client.get("/api/users/").await;
581	/// # });
582	/// ```
583	pub async fn get(&self, path: &str) -> ClientResult<TestResponse> {
584		self.request(Method::GET, path, None, None).await
585	}
586	/// Make a POST request
587	///
588	/// # Examples
589	///
590	/// ```
591	/// use reinhardt_testkit::client::APIClient;
592	/// use serde_json::json;
593	///
594	/// # tokio_test::block_on(async {
595	/// let client = APIClient::new();
596	/// let data = json!({"name": "test"});
597	// Note: post() requires a working handler
598	// let response = client.post("/api/users/", &data, "json").await;
599	/// # });
600	/// ```
601	pub async fn post<T: Serialize>(
602		&self,
603		path: &str,
604		data: &T,
605		format: &str,
606	) -> ClientResult<TestResponse> {
607		let body = self.serialize_data(data, format)?;
608		let content_type = self.get_content_type(format);
609		self.request(Method::POST, path, Some(body), Some(content_type))
610			.await
611	}
612	/// Make a PUT request
613	///
614	/// # Examples
615	///
616	/// ```
617	/// use reinhardt_testkit::client::APIClient;
618	/// use serde_json::json;
619	///
620	/// # tokio_test::block_on(async {
621	/// let client = APIClient::new();
622	/// let data = json!({"name": "updated"});
623	// Note: put() requires a working handler
624	// let response = client.put("/api/users/1/", &data, "json").await;
625	/// # });
626	/// ```
627	pub async fn put<T: Serialize>(
628		&self,
629		path: &str,
630		data: &T,
631		format: &str,
632	) -> ClientResult<TestResponse> {
633		let body = self.serialize_data(data, format)?;
634		let content_type = self.get_content_type(format);
635		self.request(Method::PUT, path, Some(body), Some(content_type))
636			.await
637	}
638	/// Make a PATCH request
639	///
640	/// # Examples
641	///
642	/// ```
643	/// use reinhardt_testkit::client::APIClient;
644	/// use serde_json::json;
645	///
646	/// # tokio_test::block_on(async {
647	/// let client = APIClient::new();
648	/// let data = json!({"name": "partial_update"});
649	// Note: patch() requires a working handler
650	// let response = client.patch("/api/users/1/", &data, "json").await;
651	/// # });
652	/// ```
653	pub async fn patch<T: Serialize>(
654		&self,
655		path: &str,
656		data: &T,
657		format: &str,
658	) -> ClientResult<TestResponse> {
659		let body = self.serialize_data(data, format)?;
660		let content_type = self.get_content_type(format);
661		self.request(Method::PATCH, path, Some(body), Some(content_type))
662			.await
663	}
664	/// Make a DELETE request
665	///
666	/// # Examples
667	///
668	/// ```
669	/// use reinhardt_testkit::client::APIClient;
670	///
671	/// # tokio_test::block_on(async {
672	/// let client = APIClient::new();
673	// Note: delete() requires a working handler
674	// let response = client.delete("/api/users/1/").await;
675	/// # });
676	/// ```
677	pub async fn delete(&self, path: &str) -> ClientResult<TestResponse> {
678		self.request(Method::DELETE, path, None, None).await
679	}
680	/// Make a HEAD request
681	///
682	/// # Examples
683	///
684	/// ```
685	/// use reinhardt_testkit::client::APIClient;
686	///
687	/// # tokio_test::block_on(async {
688	/// let client = APIClient::new();
689	// Note: head() requires a working handler
690	// let response = client.head("/api/users/").await;
691	/// # });
692	/// ```
693	pub async fn head(&self, path: &str) -> ClientResult<TestResponse> {
694		self.request(Method::HEAD, path, None, None).await
695	}
696	/// Make an OPTIONS request
697	///
698	/// # Examples
699	///
700	/// ```
701	/// use reinhardt_testkit::client::APIClient;
702	///
703	/// # tokio_test::block_on(async {
704	/// let client = APIClient::new();
705	// Note: options() requires a working handler
706	// let response = client.options("/api/users/").await;
707	/// # });
708	/// ```
709	pub async fn options(&self, path: &str) -> ClientResult<TestResponse> {
710		self.request(Method::OPTIONS, path, None, None).await
711	}
712
713	/// Make a GET request with additional per-request headers
714	///
715	/// # Examples
716	///
717	/// ```
718	/// use reinhardt_testkit::client::APIClient;
719	///
720	/// # tokio_test::block_on(async {
721	/// let client = APIClient::with_base_url("http://localhost:8080");
722	/// // let response = client.get_with_headers("/api/data", &[("Accept", "application/json")]).await;
723	/// # });
724	/// ```
725	pub async fn get_with_headers(
726		&self,
727		path: &str,
728		headers: &[(&str, &str)],
729	) -> ClientResult<TestResponse> {
730		self.request_with_extra_headers(Method::GET, path, None, None, headers)
731			.await
732	}
733
734	/// Make a POST request with raw body and additional per-request headers
735	///
736	/// Unlike `post()`, this method allows setting a raw body without automatic serialization.
737	///
738	/// # Examples
739	///
740	/// ```
741	/// use reinhardt_testkit::client::APIClient;
742	///
743	/// # tokio_test::block_on(async {
744	/// let client = APIClient::with_base_url("http://localhost:8080");
745	/// // let response = client.post_raw_with_headers(
746	/// //     "/api/echo",
747	/// //     b"{\"test\":\"data\"}",
748	/// //     "application/json",
749	/// //     &[("X-Custom-Header", "value")]
750	/// // ).await;
751	/// # });
752	/// ```
753	pub async fn post_raw_with_headers(
754		&self,
755		path: &str,
756		body: &[u8],
757		content_type: &str,
758		headers: &[(&str, &str)],
759	) -> ClientResult<TestResponse> {
760		self.request_with_extra_headers(
761			Method::POST,
762			path,
763			Some(Bytes::copy_from_slice(body)),
764			Some(content_type),
765			headers,
766		)
767		.await
768	}
769
770	/// Make a POST request with raw body
771	///
772	/// Unlike `post()`, this method allows setting a raw body without automatic serialization.
773	///
774	/// # Examples
775	///
776	/// ```
777	/// use reinhardt_testkit::client::APIClient;
778	///
779	/// # tokio_test::block_on(async {
780	/// let client = APIClient::with_base_url("http://localhost:8080");
781	/// // let response = client.post_raw("/api/echo", b"{\"test\":\"data\"}", "application/json").await;
782	/// # });
783	/// ```
784	pub async fn post_raw(
785		&self,
786		path: &str,
787		body: &[u8],
788		content_type: &str,
789	) -> ClientResult<TestResponse> {
790		self.request(
791			Method::POST,
792			path,
793			Some(Bytes::copy_from_slice(body)),
794			Some(content_type),
795		)
796		.await
797	}
798
799	/// Generic request method
800	async fn request(
801		&self,
802		method: Method,
803		path: &str,
804		body: Option<Bytes>,
805		content_type: Option<&str>,
806	) -> ClientResult<TestResponse> {
807		self.request_with_extra_headers(method, path, body, content_type, &[])
808			.await
809	}
810
811	/// Generic request method with additional per-request headers
812	///
813	/// This method is similar to `request()` but allows adding extra headers
814	/// that are specific to this request only, without modifying the default headers.
815	async fn request_with_extra_headers(
816		&self,
817		method: Method,
818		path: &str,
819		body: Option<Bytes>,
820		content_type: Option<&str>,
821		extra_headers: &[(&str, &str)],
822	) -> ClientResult<TestResponse> {
823		let url = if path.starts_with("http://") || path.starts_with("https://") {
824			path.to_string()
825		} else {
826			format!("{}{}", self.base_url, path)
827		};
828
829		let mut req_builder = Request::builder().method(method).uri(url);
830
831		// Add default headers
832		let default_headers = self.default_headers.read().await;
833		for (name, value) in default_headers.iter() {
834			req_builder = req_builder.header(name, value);
835		}
836
837		// Add extra per-request headers (these override default headers if same name)
838		for (name, value) in extra_headers {
839			req_builder = req_builder.header(*name, *value);
840		}
841
842		// Add content type if provided
843		if let Some(ct) = content_type {
844			req_builder = req_builder.header("Content-Type", ct);
845		}
846
847		// Add cookies (with validation to prevent header injection)
848		let cookies = self.cookies.read().await;
849		if !cookies.is_empty() {
850			let cookie_header = cookies
851				.iter()
852				.map(|(k, v)| {
853					validate_cookie_key(k);
854					validate_cookie_value(v);
855					format!("{}={}", k, v)
856				})
857				.collect::<Vec<_>>()
858				.join("; ");
859			req_builder = req_builder.header("Cookie", cookie_header);
860		}
861
862		// Add authentication if user is set
863		let user = self.user.read().await;
864		if user.is_some() {
865			// Add custom header to indicate forced authentication
866			req_builder = req_builder.header("X-Test-User", "authenticated");
867		}
868
869		// Build request with body
870		let request = if let Some(body_bytes) = body {
871			req_builder.body(Full::new(body_bytes))?
872		} else {
873			req_builder.body(Full::new(Bytes::new()))?
874		};
875
876		// Execute request
877		let response = if let Some(async_handler) = &self.async_handler {
878			// In-process dispatch via framework Handler trait
879			let (parts, body) = request.into_parts();
880			let body_bytes = body
881				.collect()
882				.await
883				.map(|c| c.to_bytes())
884				.unwrap_or_else(|_| Bytes::new());
885
886			let mut fw_request = HttpRequest::builder()
887				.method(parts.method)
888				.uri(parts.uri)
889				.version(parts.version)
890				.headers(parts.headers)
891				.body(body_bytes)
892				.build()
893				.expect("Failed to build reinhardt request");
894
895			if let Some(ctx) = &self.handler_di_context {
896				fw_request.set_di_context(Arc::clone(ctx));
897			}
898
899			let fw_response = async_handler
900				.handle(fw_request)
901				.await
902				.unwrap_or_else(HttpResponse::from);
903
904			let mut builder = http::Response::builder().status(fw_response.status);
905			for (key, value) in fw_response.headers.iter() {
906				builder = builder.header(key, value);
907			}
908			builder
909				.body(Full::new(fw_response.body))
910				.expect("Failed to build http::Response")
911		} else if let Some(handler) = &self.handler {
912			// Use custom sync handler if set
913			handler(request)
914		} else {
915			// Use reqwest for real HTTP requests when no handler is set
916			let (parts, body) = request.into_parts();
917
918			// Build reqwest request
919			let url = if parts.uri.scheme_str().is_some() {
920				// Absolute URL
921				parts.uri.to_string()
922			} else {
923				// Relative path - use base_url
924				format!(
925					"{}{}",
926					self.base_url.trim_end_matches('/'),
927					parts.uri.path()
928				)
929			};
930
931			// Use the stored http_client (connection pooling enabled)
932			let mut reqwest_request = self.http_client.request(
933				reqwest::Method::from_bytes(parts.method.as_str().as_bytes()).unwrap(),
934				&url,
935			);
936
937			// Copy headers (skip Cookie if using cookie_store, as reqwest manages it automatically)
938			for (name, value) in parts.headers.iter() {
939				if self.use_cookie_store && name.as_str().eq_ignore_ascii_case("cookie") {
940					continue;
941				}
942				reqwest_request = reqwest_request.header(name.as_str(), value.as_bytes());
943			}
944
945			// Copy body
946			let body_bytes = body
947				.collect()
948				.await
949				.map(|c| c.to_bytes())
950				.unwrap_or_else(|_| Bytes::new());
951			if !body_bytes.is_empty() {
952				reqwest_request = reqwest_request.body(body_bytes.to_vec());
953			}
954
955			// Execute reqwest request
956			let reqwest_response = reqwest_request.send().await?;
957
958			// Convert reqwest response to http::Response
959			let status = reqwest_response.status();
960			let version = reqwest_response.version();
961			let headers = reqwest_response.headers().clone();
962			let body_bytes = reqwest_response.bytes().await?;
963
964			let mut response_builder = Response::builder().status(status).version(version);
965			for (name, value) in headers.iter() {
966				response_builder = response_builder.header(name, value);
967			}
968
969			response_builder.body(Full::new(body_bytes))?
970		};
971
972		// Extract body from response using async collection
973		let (parts, response_body) = response.into_parts();
974		let body_data = response_body
975			.collect()
976			.await
977			.map(|collected| collected.to_bytes())
978			.unwrap_or_else(|_| Bytes::new());
979
980		Ok(TestResponse::with_body_and_version(
981			parts.status,
982			parts.headers,
983			body_data,
984			parts.version,
985		))
986	}
987
988	/// Serialize data based on format
989	fn serialize_data<T: Serialize>(&self, data: &T, format: &str) -> ClientResult<Bytes> {
990		match format {
991			"json" => {
992				let json = serde_json::to_vec(data)?;
993				Ok(Bytes::from(json))
994			}
995			"form" => {
996				// URL-encoded form data
997				let json_value = serde_json::to_value(data)?;
998				if let Value::Object(map) = json_value {
999					let form_data = map
1000						.iter()
1001						.map(|(k, v)| {
1002							let value_str = match v {
1003								Value::String(s) => s.clone(),
1004								_ => v.to_string(),
1005							};
1006							format!(
1007								"{}={}",
1008								urlencoding::encode(k),
1009								urlencoding::encode(&value_str)
1010							)
1011						})
1012						.collect::<Vec<_>>()
1013						.join("&");
1014					Ok(Bytes::from(form_data))
1015				} else {
1016					Err(ClientError::RequestFailed(
1017						"Expected object for form data".to_string(),
1018					))
1019				}
1020			}
1021			_ => Err(ClientError::RequestFailed(format!(
1022				"Unsupported format: {}",
1023				format
1024			))),
1025		}
1026	}
1027
1028	/// Get content type for format
1029	fn get_content_type(&self, format: &str) -> &str {
1030		match format {
1031			"json" => "application/json",
1032			"form" => "application/x-www-form-urlencoded",
1033			_ => "application/octet-stream",
1034		}
1035	}
1036}
1037
1038/// Validate a cookie key to prevent header injection attacks.
1039///
1040/// Cookie keys must not contain `=`, `;`, whitespace, or control characters.
1041///
1042/// # Panics
1043///
1044/// Panics if the cookie key contains invalid characters.
1045fn validate_cookie_key(key: &str) {
1046	assert!(!key.is_empty(), "cookie key must not be empty");
1047	assert!(
1048		!key.contains('='),
1049		"cookie key must not contain '=' (found in key: {:?})",
1050		key
1051	);
1052	assert!(
1053		!key.contains(';'),
1054		"cookie key must not contain ';' (found in key: {:?})",
1055		key
1056	);
1057	assert!(
1058		!key.chars().any(|c| c.is_ascii_whitespace()),
1059		"cookie key must not contain whitespace (found in key: {:?})",
1060		key
1061	);
1062	assert!(
1063		!key.chars().any(|c| c.is_control()),
1064		"cookie key must not contain control characters (found in key: {:?})",
1065		key
1066	);
1067}
1068
1069/// Validate a cookie value to prevent header injection attacks.
1070///
1071/// Cookie values must not contain `;`, newlines (`\r`, `\n`), or control characters.
1072///
1073/// # Panics
1074///
1075/// Panics if the cookie value contains invalid characters.
1076fn validate_cookie_value(value: &str) {
1077	assert!(!value.contains(';'), "cookie value must not contain ';'");
1078	assert!(
1079		!value.contains('\r') && !value.contains('\n'),
1080		"cookie value must not contain newlines"
1081	);
1082	assert!(
1083		!value.chars().any(|c| c.is_control()),
1084		"cookie value must not contain control characters"
1085	);
1086}
1087
1088impl Default for APIClient {
1089	fn default() -> Self {
1090		Self::new()
1091	}
1092}
1093
1094// Need to add base64 dependency
1095mod base64 {
1096	pub(super) fn encode(input: String) -> String {
1097		// Simple base64 encoding (in production, use a proper library)
1098		use base64_simd::STANDARD;
1099		STANDARD.encode_to_string(input.as_bytes())
1100	}
1101}
1102
1103// Need to add urlencoding
1104mod urlencoding {
1105	pub(super) fn encode(input: &str) -> String {
1106		url::form_urlencoded::byte_serialize(input.as_bytes()).collect()
1107	}
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112	use super::*;
1113	use async_trait::async_trait;
1114	use reinhardt_core::exception::{Error as HttpError, Result as HttpResult};
1115	use rstest::rstest;
1116
1117	/// Handler that echoes the request path in the response body
1118	/// and reflects request headers as X-Echo-* response headers.
1119	struct EchoHandler;
1120
1121	#[async_trait]
1122	impl HttpHandler for EchoHandler {
1123		async fn handle(&self, request: HttpRequest) -> HttpResult<HttpResponse> {
1124			let path = request.uri.path().to_string();
1125			let has_custom = request.headers.get("X-Custom").is_some();
1126			let content_type = request
1127				.headers
1128				.get("Content-Type")
1129				.and_then(|v| v.to_str().ok())
1130				.unwrap_or("")
1131				.to_string();
1132
1133			let mut response = HttpResponse::ok().with_body(path.clone());
1134			response = response.try_with_header("X-Echo-Path", &path)?;
1135
1136			if has_custom {
1137				response = response.try_with_header("X-Echo-Custom", "present")?;
1138			}
1139			if !content_type.is_empty() {
1140				response = response.try_with_header("X-Echo-Content-Type", &content_type)?;
1141			}
1142			Ok(response)
1143		}
1144	}
1145
1146	/// Handler that always returns an error.
1147	struct ErrorHandler;
1148
1149	#[async_trait]
1150	impl HttpHandler for ErrorHandler {
1151		async fn handle(&self, _request: HttpRequest) -> HttpResult<HttpResponse> {
1152			Err(HttpError::NotFound("test resource".to_string()))
1153		}
1154	}
1155
1156	#[rstest]
1157	#[tokio::test]
1158	async fn test_from_handler_basic() {
1159		// Arrange
1160		let client = APIClient::from_handler(EchoHandler);
1161
1162		// Act
1163		let response = client.get("/test/path/").await.expect("request failed");
1164
1165		// Assert
1166		assert_eq!(response.status(), http::StatusCode::OK);
1167		assert_eq!(response.body().as_ref(), b"/test/path/");
1168	}
1169
1170	#[rstest]
1171	#[tokio::test]
1172	async fn test_from_handler_post_body() {
1173		// Arrange
1174		let client = APIClient::from_handler(EchoHandler);
1175		let body = serde_json::json!({"key": "value"});
1176
1177		// Act
1178		let response = client
1179			.post("/echo/", &body, "json")
1180			.await
1181			.expect("request failed");
1182
1183		// Assert
1184		assert_eq!(response.status(), http::StatusCode::OK);
1185		assert_eq!(
1186			response
1187				.header("X-Echo-Content-Type")
1188				.expect("missing header"),
1189			"application/json"
1190		);
1191	}
1192
1193	#[rstest]
1194	#[tokio::test]
1195	async fn test_from_handler_headers() {
1196		// Arrange
1197		let client = APIClient::from_handler(EchoHandler);
1198		client
1199			.set_header("X-Custom", "test-value")
1200			.await
1201			.expect("set_header failed");
1202
1203		// Act
1204		let response = client.get("/test/").await.expect("request failed");
1205
1206		// Assert
1207		assert_eq!(response.status(), http::StatusCode::OK);
1208		assert_eq!(
1209			response.header("X-Echo-Custom").expect("missing header"),
1210			"present"
1211		);
1212	}
1213
1214	#[rstest]
1215	#[tokio::test]
1216	async fn test_from_handler_error_conversion() {
1217		// Arrange
1218		let client = APIClient::from_handler(ErrorHandler);
1219
1220		// Act
1221		let response = client.get("/anything/").await.expect("request failed");
1222
1223		// Assert
1224		assert_eq!(response.status(), http::StatusCode::NOT_FOUND);
1225	}
1226
1227	#[rstest]
1228	#[tokio::test]
1229	async fn test_from_handler_origin_header() {
1230		// Arrange
1231		let client = APIClient::from_handler(EchoHandler);
1232
1233		// Act
1234		let headers = client.default_headers.read().await;
1235
1236		// Assert
1237		let origin = headers
1238			.get(http::header::ORIGIN)
1239			.expect("Origin header not set");
1240		assert_eq!(origin.to_str().unwrap(), "http://testserver");
1241	}
1242
1243	#[rstest]
1244	#[tokio::test]
1245	async fn test_builder_with_handler() {
1246		// Arrange
1247		let client = APIClient::builder()
1248			.base_url("http://mytest")
1249			.handler(EchoHandler)
1250			.build();
1251
1252		// Act
1253		let response = client.get("/api/").await.expect("request failed");
1254
1255		// Assert
1256		assert_eq!(response.status(), http::StatusCode::OK);
1257		let headers = client.default_headers.read().await;
1258		let origin = headers
1259			.get(http::header::ORIGIN)
1260			.expect("Origin header not set");
1261		assert_eq!(origin.to_str().unwrap(), "http://mytest");
1262	}
1263
1264	#[rstest]
1265	fn test_validate_cookie_key_accepts_valid_key() {
1266		// Arrange
1267		let key = "session_id";
1268
1269		// Act & Assert (should not panic)
1270		validate_cookie_key(key);
1271	}
1272
1273	#[rstest]
1274	#[should_panic(expected = "must not be empty")]
1275	fn test_validate_cookie_key_rejects_empty() {
1276		// Arrange
1277		let key = "";
1278
1279		// Act
1280		validate_cookie_key(key);
1281	}
1282
1283	#[rstest]
1284	#[should_panic(expected = "must not contain '='")]
1285	fn test_validate_cookie_key_rejects_equals_sign() {
1286		// Arrange
1287		let key = "key=value";
1288
1289		// Act
1290		validate_cookie_key(key);
1291	}
1292
1293	#[rstest]
1294	#[should_panic(expected = "must not contain ';'")]
1295	fn test_validate_cookie_key_rejects_semicolon() {
1296		// Arrange
1297		let key = "key;injection";
1298
1299		// Act
1300		validate_cookie_key(key);
1301	}
1302
1303	#[rstest]
1304	#[should_panic(expected = "must not contain whitespace")]
1305	fn test_validate_cookie_key_rejects_whitespace() {
1306		// Arrange
1307		let key = "key name";
1308
1309		// Act
1310		validate_cookie_key(key);
1311	}
1312
1313	#[rstest]
1314	#[should_panic(expected = "must not contain control characters")]
1315	fn test_validate_cookie_key_rejects_control_chars() {
1316		// Arrange
1317		let key = "key\x00name";
1318
1319		// Act
1320		validate_cookie_key(key);
1321	}
1322
1323	#[rstest]
1324	fn test_validate_cookie_value_accepts_valid_value() {
1325		// Arrange
1326		let value = "abc123-token";
1327
1328		// Act & Assert (should not panic)
1329		validate_cookie_value(value);
1330	}
1331
1332	#[rstest]
1333	fn test_validate_cookie_value_accepts_empty() {
1334		// Arrange
1335		let value = "";
1336
1337		// Act & Assert (should not panic)
1338		validate_cookie_value(value);
1339	}
1340
1341	#[rstest]
1342	#[should_panic(expected = "must not contain ';'")]
1343	fn test_validate_cookie_value_rejects_semicolon() {
1344		// Arrange
1345		let value = "value; extra=injected";
1346
1347		// Act
1348		validate_cookie_value(value);
1349	}
1350
1351	#[rstest]
1352	#[should_panic(expected = "must not contain newlines")]
1353	fn test_validate_cookie_value_rejects_newline() {
1354		// Arrange
1355		let value = "value\r\nInjected-Header: malicious";
1356
1357		// Act
1358		validate_cookie_value(value);
1359	}
1360
1361	#[rstest]
1362	#[should_panic(expected = "must not contain control characters")]
1363	fn test_validate_cookie_value_rejects_control_chars() {
1364		// Arrange
1365		let value = "value\x01hidden";
1366
1367		// Act
1368		validate_cookie_value(value);
1369	}
1370
1371	#[rstest]
1372	#[should_panic(expected = "must not contain newlines")]
1373	fn test_validate_cookie_value_rejects_lf_only() {
1374		// Arrange
1375		let value = "value\nInjected-Header: evil";
1376
1377		// Act
1378		validate_cookie_value(value);
1379	}
1380}