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 crate::response::TestResponse;
18
19/// HTTP version configuration for APIClient
20#[derive(Debug, Clone, Copy, Default)]
21pub enum HttpVersion {
22	/// Use HTTP/1.1 only
23	Http1Only,
24	/// Use HTTP/2 with prior knowledge (no upgrade negotiation)
25	Http2PriorKnowledge,
26	/// Auto-negotiate (default)
27	#[default]
28	Auto,
29}
30
31/// Errors that can occur when using the API test client.
32#[derive(Debug, Error)]
33pub enum ClientError {
34	/// HTTP protocol error.
35	#[error("HTTP error: {0}")]
36	Http(#[from] http::Error),
37
38	/// Hyper transport error.
39	#[error("Hyper error: {0}")]
40	Hyper(#[from] hyper::Error),
41
42	/// JSON serialization/deserialization error.
43	#[error("Serialization error: {0}")]
44	Serialization(#[from] serde_json::Error),
45
46	/// Invalid HTTP header value.
47	#[error("Invalid header value: {0}")]
48	InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
49
50	/// Reqwest HTTP client error.
51	#[error("Reqwest error: {0}")]
52	Reqwest(#[from] reqwest::Error),
53
54	/// General request failure.
55	#[error("Request failed: {0}")]
56	RequestFailed(String),
57}
58
59impl ClientError {
60	/// Returns true if the error is a timeout error
61	pub fn is_timeout(&self) -> bool {
62		match self {
63			ClientError::Reqwest(e) => e.is_timeout(),
64			_ => false,
65		}
66	}
67
68	/// Returns true if the error is a connection error
69	pub fn is_connect(&self) -> bool {
70		match self {
71			ClientError::Reqwest(e) => e.is_connect(),
72			_ => false,
73		}
74	}
75
76	/// Returns true if the error occurred during request building
77	pub fn is_request(&self) -> bool {
78		match self {
79			ClientError::Reqwest(e) => e.is_request(),
80			ClientError::Http(_) => true,
81			ClientError::InvalidHeaderValue(_) => true,
82			ClientError::Serialization(_) => true,
83			ClientError::RequestFailed(_) => true,
84			_ => false,
85		}
86	}
87}
88
89/// Result type for API client operations.
90pub type ClientResult<T> = Result<T, ClientError>;
91
92/// Type alias for request handler function
93pub type RequestHandler = Arc<dyn Fn(Request<Full<Bytes>>) -> Response<Full<Bytes>> + Send + Sync>;
94
95/// Builder for creating APIClient with custom configuration
96///
97/// # Example
98/// ```rust,no_run
99/// use reinhardt_testkit::client::{APIClientBuilder, HttpVersion};
100/// use std::time::Duration;
101///
102/// let client = APIClientBuilder::new()
103///     .base_url("http://localhost:8080")
104///     .timeout(Duration::from_secs(30))
105///     .http_version(HttpVersion::Http2PriorKnowledge)
106///     .cookie_store(true)
107///     .build();
108/// ```
109pub struct APIClientBuilder {
110	base_url: String,
111	timeout: Option<Duration>,
112	http_version: HttpVersion,
113	cookie_store: bool,
114}
115
116impl APIClientBuilder {
117	/// Create a new builder with default configuration
118	pub fn new() -> Self {
119		Self {
120			base_url: "http://testserver".to_string(),
121			timeout: None,
122			http_version: HttpVersion::Auto,
123			cookie_store: false,
124		}
125	}
126
127	/// Set the base URL for requests
128	pub fn base_url(mut self, url: impl Into<String>) -> Self {
129		self.base_url = url.into();
130		self
131	}
132
133	/// Set the request timeout
134	pub fn timeout(mut self, duration: Duration) -> Self {
135		self.timeout = Some(duration);
136		self
137	}
138
139	/// Set the HTTP version
140	pub fn http_version(mut self, version: HttpVersion) -> Self {
141		self.http_version = version;
142		self
143	}
144
145	/// Use HTTP/1.1 only (convenience method)
146	pub fn http1_only(mut self) -> Self {
147		self.http_version = HttpVersion::Http1Only;
148		self
149	}
150
151	/// Use HTTP/2 with prior knowledge (convenience method)
152	pub fn http2_prior_knowledge(mut self) -> Self {
153		self.http_version = HttpVersion::Http2PriorKnowledge;
154		self
155	}
156
157	/// Enable or disable automatic cookie storage
158	pub fn cookie_store(mut self, enabled: bool) -> Self {
159		self.cookie_store = enabled;
160		self
161	}
162
163	/// Build the APIClient
164	pub fn build(self) -> APIClient {
165		let mut client_builder = reqwest::Client::builder();
166
167		// Configure timeout
168		if let Some(timeout) = self.timeout {
169			client_builder = client_builder.timeout(timeout);
170		}
171
172		// Configure HTTP version
173		match self.http_version {
174			HttpVersion::Http1Only => {
175				client_builder = client_builder.http1_only();
176			}
177			HttpVersion::Http2PriorKnowledge => {
178				client_builder = client_builder.http2_prior_knowledge();
179			}
180			HttpVersion::Auto => {
181				// Default behavior, no special configuration needed
182			}
183		}
184
185		// Configure cookie store
186		if self.cookie_store {
187			client_builder = client_builder.cookie_store(true);
188		}
189
190		let http_client = client_builder
191			.build()
192			.expect("Failed to build reqwest client");
193
194		APIClient {
195			base_url: self.base_url,
196			default_headers: Arc::new(RwLock::new(HeaderMap::new())),
197			cookies: Arc::new(RwLock::new(HashMap::new())),
198			user: Arc::new(RwLock::new(None)),
199			handler: None,
200			http_client,
201			use_cookie_store: self.cookie_store,
202		}
203	}
204}
205
206impl Default for APIClientBuilder {
207	fn default() -> Self {
208		Self::new()
209	}
210}
211
212/// Test client for making API requests
213///
214/// # Example
215/// ```rust,no_run
216/// use reinhardt_testkit::APIClient;
217/// use http::StatusCode;
218/// use serde_json::json;
219///
220/// # #[tokio::main]
221/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
222/// let client = APIClient::with_base_url("http://localhost:8080");
223/// let credentials = json!({"username": "user", "password": "pass"});
224/// client.post("/auth/login", &credentials, "json").await?;
225/// let response = client.get("/api/users/").await?;
226/// assert_eq!(response.status(), StatusCode::OK);
227/// # Ok(())
228/// # }
229/// ```
230pub struct APIClient {
231	/// Base URL for requests (e.g., "http://testserver")
232	base_url: String,
233
234	/// Default headers to include in all requests
235	default_headers: Arc<RwLock<HeaderMap>>,
236
237	/// Cookies to include in requests (manual management)
238	cookies: Arc<RwLock<HashMap<String, String>>>,
239
240	/// Current authenticated user (if any)
241	user: Arc<RwLock<Option<Value>>>,
242
243	/// Handler function for processing requests
244	handler: Option<RequestHandler>,
245
246	/// Reusable HTTP client with connection pooling
247	http_client: reqwest::Client,
248
249	/// Whether automatic cookie storage is enabled
250	use_cookie_store: bool,
251}
252
253impl APIClient {
254	/// Create a new API client
255	///
256	/// # Examples
257	///
258	/// ```
259	/// use reinhardt_testkit::client::APIClient;
260	///
261	/// let client = APIClient::new();
262	/// assert_eq!(client.base_url(), "http://testserver");
263	/// ```
264	pub fn new() -> Self {
265		APIClientBuilder::new().build()
266	}
267
268	/// Create a client with a custom base URL
269	///
270	/// # Examples
271	///
272	/// ```
273	/// use reinhardt_testkit::client::APIClient;
274	///
275	/// let client = APIClient::with_base_url("https://api.example.com");
276	/// assert_eq!(client.base_url(), "https://api.example.com");
277	/// ```
278	pub fn with_base_url(base_url: impl Into<String>) -> Self {
279		APIClientBuilder::new().base_url(base_url).build()
280	}
281
282	/// Create a builder for customizing the client configuration
283	///
284	/// # Examples
285	///
286	/// ```
287	/// use reinhardt_testkit::client::APIClient;
288	/// use std::time::Duration;
289	///
290	/// let client = APIClient::builder()
291	///     .base_url("http://localhost:8080")
292	///     .timeout(Duration::from_secs(30))
293	///     .build();
294	/// ```
295	pub fn builder() -> APIClientBuilder {
296		APIClientBuilder::new()
297	}
298	/// Get the base URL of this client.
299	pub fn base_url(&self) -> &str {
300		&self.base_url
301	}
302	/// Set a request handler for testing
303	///
304	/// # Examples
305	///
306	/// ```
307	/// use reinhardt_testkit::client::APIClient;
308	/// use http::{Request, Response, StatusCode};
309	/// use http_body_util::Full;
310	/// use bytes::Bytes;
311	///
312	/// let mut client = APIClient::new();
313	/// client.set_handler(|_req| {
314	///     Response::builder()
315	///         .status(StatusCode::OK)
316	///         .body(Full::new(Bytes::from("test")))
317	///         .unwrap()
318	/// });
319	/// ```
320	pub fn set_handler<F>(&mut self, handler: F)
321	where
322		F: Fn(Request<Full<Bytes>>) -> Response<Full<Bytes>> + Send + Sync + 'static,
323	{
324		self.handler = Some(Arc::new(handler));
325	}
326	/// Set a default header for all requests
327	///
328	/// # Examples
329	///
330	/// ```
331	/// use reinhardt_testkit::client::APIClient;
332	///
333	/// # tokio_test::block_on(async {
334	/// let client = APIClient::new();
335	/// client.set_header("User-Agent", "TestClient/1.0").await.unwrap();
336	/// # });
337	/// ```
338	pub async fn set_header(
339		&self,
340		name: impl AsRef<str>,
341		value: impl AsRef<str>,
342	) -> ClientResult<()> {
343		let mut headers = self.default_headers.write().await;
344		let header_name: http::header::HeaderName = name.as_ref().parse().map_err(|_| {
345			ClientError::RequestFailed(format!("Invalid header name: {}", name.as_ref()))
346		})?;
347		headers.insert(header_name, HeaderValue::from_str(value.as_ref())?);
348		Ok(())
349	}
350	/// Force authenticate as a user (for testing)
351	///
352	/// # Examples
353	///
354	/// ```
355	/// use reinhardt_testkit::client::APIClient;
356	/// use serde_json::json;
357	///
358	/// # tokio_test::block_on(async {
359	/// let client = APIClient::new();
360	/// let user = json!({"id": 1, "username": "testuser"});
361	/// client.force_authenticate(Some(user)).await;
362	/// # });
363	/// ```
364	pub async fn force_authenticate(&self, user: Option<Value>) {
365		let mut current_user = self.user.write().await;
366		*current_user = user;
367	}
368	/// Set credentials for Basic Authentication
369	///
370	/// # Examples
371	///
372	/// ```
373	/// use reinhardt_testkit::client::APIClient;
374	///
375	/// # tokio_test::block_on(async {
376	/// let client = APIClient::new();
377	/// client.credentials("username", "password").await.unwrap();
378	/// # });
379	/// ```
380	pub async fn credentials(&self, username: &str, password: &str) -> ClientResult<()> {
381		let encoded = base64::encode(format!("{}:{}", username, password));
382		self.set_header("Authorization", format!("Basic {}", encoded))
383			.await
384	}
385	/// Clear authentication and cookies
386	///
387	/// # Examples
388	///
389	/// ```
390	/// use reinhardt_testkit::client::APIClient;
391	///
392	/// # tokio_test::block_on(async {
393	/// let client = APIClient::new();
394	/// client.clear_auth().await.unwrap();
395	/// # });
396	/// ```
397	pub async fn clear_auth(&self) -> ClientResult<()> {
398		self.force_authenticate(None).await;
399		let mut cookies = self.cookies.write().await;
400		cookies.clear();
401		Ok(())
402	}
403
404	/// Clean up all client state for teardown
405	///
406	/// This method performs a complete cleanup of the client state including:
407	/// - Clearing authentication
408	/// - Clearing cookies
409	/// - Clearing default headers
410	///
411	/// This is typically called during test teardown to ensure clean state
412	/// between tests.
413	///
414	/// # Examples
415	///
416	/// ```
417	/// use reinhardt_testkit::client::APIClient;
418	///
419	/// # tokio_test::block_on(async {
420	/// let client = APIClient::new();
421	/// client.set_header("X-Custom", "value").await.unwrap();
422	/// client.cleanup().await;
423	/// // All state is now cleared
424	/// # });
425	/// ```
426	pub async fn cleanup(&self) {
427		// Clear authentication
428		self.force_authenticate(None).await;
429
430		// Clear cookies
431		{
432			let mut cookies = self.cookies.write().await;
433			cookies.clear();
434		}
435
436		// Clear default headers
437		{
438			let mut headers = self.default_headers.write().await;
439			headers.clear();
440		}
441	}
442	/// Make a GET request
443	///
444	/// # Examples
445	///
446	/// ```
447	/// use reinhardt_testkit::client::APIClient;
448	///
449	/// # tokio_test::block_on(async {
450	/// let client = APIClient::new();
451	// Note: get() requires a working handler
452	// let response = client.get("/api/users/").await;
453	/// # });
454	/// ```
455	pub async fn get(&self, path: &str) -> ClientResult<TestResponse> {
456		self.request(Method::GET, path, None, None).await
457	}
458	/// Make a POST request
459	///
460	/// # Examples
461	///
462	/// ```
463	/// use reinhardt_testkit::client::APIClient;
464	/// use serde_json::json;
465	///
466	/// # tokio_test::block_on(async {
467	/// let client = APIClient::new();
468	/// let data = json!({"name": "test"});
469	// Note: post() requires a working handler
470	// let response = client.post("/api/users/", &data, "json").await;
471	/// # });
472	/// ```
473	pub async fn post<T: Serialize>(
474		&self,
475		path: &str,
476		data: &T,
477		format: &str,
478	) -> ClientResult<TestResponse> {
479		let body = self.serialize_data(data, format)?;
480		let content_type = self.get_content_type(format);
481		self.request(Method::POST, path, Some(body), Some(content_type))
482			.await
483	}
484	/// Make a PUT request
485	///
486	/// # Examples
487	///
488	/// ```
489	/// use reinhardt_testkit::client::APIClient;
490	/// use serde_json::json;
491	///
492	/// # tokio_test::block_on(async {
493	/// let client = APIClient::new();
494	/// let data = json!({"name": "updated"});
495	// Note: put() requires a working handler
496	// let response = client.put("/api/users/1/", &data, "json").await;
497	/// # });
498	/// ```
499	pub async fn put<T: Serialize>(
500		&self,
501		path: &str,
502		data: &T,
503		format: &str,
504	) -> ClientResult<TestResponse> {
505		let body = self.serialize_data(data, format)?;
506		let content_type = self.get_content_type(format);
507		self.request(Method::PUT, path, Some(body), Some(content_type))
508			.await
509	}
510	/// Make a PATCH request
511	///
512	/// # Examples
513	///
514	/// ```
515	/// use reinhardt_testkit::client::APIClient;
516	/// use serde_json::json;
517	///
518	/// # tokio_test::block_on(async {
519	/// let client = APIClient::new();
520	/// let data = json!({"name": "partial_update"});
521	// Note: patch() requires a working handler
522	// let response = client.patch("/api/users/1/", &data, "json").await;
523	/// # });
524	/// ```
525	pub async fn patch<T: Serialize>(
526		&self,
527		path: &str,
528		data: &T,
529		format: &str,
530	) -> ClientResult<TestResponse> {
531		let body = self.serialize_data(data, format)?;
532		let content_type = self.get_content_type(format);
533		self.request(Method::PATCH, path, Some(body), Some(content_type))
534			.await
535	}
536	/// Make a DELETE request
537	///
538	/// # Examples
539	///
540	/// ```
541	/// use reinhardt_testkit::client::APIClient;
542	///
543	/// # tokio_test::block_on(async {
544	/// let client = APIClient::new();
545	// Note: delete() requires a working handler
546	// let response = client.delete("/api/users/1/").await;
547	/// # });
548	/// ```
549	pub async fn delete(&self, path: &str) -> ClientResult<TestResponse> {
550		self.request(Method::DELETE, path, None, None).await
551	}
552	/// Make a HEAD 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: head() requires a working handler
562	// let response = client.head("/api/users/").await;
563	/// # });
564	/// ```
565	pub async fn head(&self, path: &str) -> ClientResult<TestResponse> {
566		self.request(Method::HEAD, path, None, None).await
567	}
568	/// Make an OPTIONS request
569	///
570	/// # Examples
571	///
572	/// ```
573	/// use reinhardt_testkit::client::APIClient;
574	///
575	/// # tokio_test::block_on(async {
576	/// let client = APIClient::new();
577	// Note: options() requires a working handler
578	// let response = client.options("/api/users/").await;
579	/// # });
580	/// ```
581	pub async fn options(&self, path: &str) -> ClientResult<TestResponse> {
582		self.request(Method::OPTIONS, path, None, None).await
583	}
584
585	/// Make a GET request with additional per-request headers
586	///
587	/// # Examples
588	///
589	/// ```
590	/// use reinhardt_testkit::client::APIClient;
591	///
592	/// # tokio_test::block_on(async {
593	/// let client = APIClient::with_base_url("http://localhost:8080");
594	/// // let response = client.get_with_headers("/api/data", &[("Accept", "application/json")]).await;
595	/// # });
596	/// ```
597	pub async fn get_with_headers(
598		&self,
599		path: &str,
600		headers: &[(&str, &str)],
601	) -> ClientResult<TestResponse> {
602		self.request_with_extra_headers(Method::GET, path, None, None, headers)
603			.await
604	}
605
606	/// Make a POST request with raw body and additional per-request headers
607	///
608	/// Unlike `post()`, this method allows setting a raw body without automatic serialization.
609	///
610	/// # Examples
611	///
612	/// ```
613	/// use reinhardt_testkit::client::APIClient;
614	///
615	/// # tokio_test::block_on(async {
616	/// let client = APIClient::with_base_url("http://localhost:8080");
617	/// // let response = client.post_raw_with_headers(
618	/// //     "/api/echo",
619	/// //     b"{\"test\":\"data\"}",
620	/// //     "application/json",
621	/// //     &[("X-Custom-Header", "value")]
622	/// // ).await;
623	/// # });
624	/// ```
625	pub async fn post_raw_with_headers(
626		&self,
627		path: &str,
628		body: &[u8],
629		content_type: &str,
630		headers: &[(&str, &str)],
631	) -> ClientResult<TestResponse> {
632		self.request_with_extra_headers(
633			Method::POST,
634			path,
635			Some(Bytes::copy_from_slice(body)),
636			Some(content_type),
637			headers,
638		)
639		.await
640	}
641
642	/// Make a POST request with raw body
643	///
644	/// Unlike `post()`, this method allows setting a raw body without automatic serialization.
645	///
646	/// # Examples
647	///
648	/// ```
649	/// use reinhardt_testkit::client::APIClient;
650	///
651	/// # tokio_test::block_on(async {
652	/// let client = APIClient::with_base_url("http://localhost:8080");
653	/// // let response = client.post_raw("/api/echo", b"{\"test\":\"data\"}", "application/json").await;
654	/// # });
655	/// ```
656	pub async fn post_raw(
657		&self,
658		path: &str,
659		body: &[u8],
660		content_type: &str,
661	) -> ClientResult<TestResponse> {
662		self.request(
663			Method::POST,
664			path,
665			Some(Bytes::copy_from_slice(body)),
666			Some(content_type),
667		)
668		.await
669	}
670
671	/// Generic request method
672	async fn request(
673		&self,
674		method: Method,
675		path: &str,
676		body: Option<Bytes>,
677		content_type: Option<&str>,
678	) -> ClientResult<TestResponse> {
679		self.request_with_extra_headers(method, path, body, content_type, &[])
680			.await
681	}
682
683	/// Generic request method with additional per-request headers
684	///
685	/// This method is similar to `request()` but allows adding extra headers
686	/// that are specific to this request only, without modifying the default headers.
687	async fn request_with_extra_headers(
688		&self,
689		method: Method,
690		path: &str,
691		body: Option<Bytes>,
692		content_type: Option<&str>,
693		extra_headers: &[(&str, &str)],
694	) -> ClientResult<TestResponse> {
695		let url = if path.starts_with("http://") || path.starts_with("https://") {
696			path.to_string()
697		} else {
698			format!("{}{}", self.base_url, path)
699		};
700
701		let mut req_builder = Request::builder().method(method).uri(url);
702
703		// Add default headers
704		let default_headers = self.default_headers.read().await;
705		for (name, value) in default_headers.iter() {
706			req_builder = req_builder.header(name, value);
707		}
708
709		// Add extra per-request headers (these override default headers if same name)
710		for (name, value) in extra_headers {
711			req_builder = req_builder.header(*name, *value);
712		}
713
714		// Add content type if provided
715		if let Some(ct) = content_type {
716			req_builder = req_builder.header("Content-Type", ct);
717		}
718
719		// Add cookies (with validation to prevent header injection)
720		let cookies = self.cookies.read().await;
721		if !cookies.is_empty() {
722			let cookie_header = cookies
723				.iter()
724				.map(|(k, v)| {
725					validate_cookie_key(k);
726					validate_cookie_value(v);
727					format!("{}={}", k, v)
728				})
729				.collect::<Vec<_>>()
730				.join("; ");
731			req_builder = req_builder.header("Cookie", cookie_header);
732		}
733
734		// Add authentication if user is set
735		let user = self.user.read().await;
736		if user.is_some() {
737			// Add custom header to indicate forced authentication
738			req_builder = req_builder.header("X-Test-User", "authenticated");
739		}
740
741		// Build request with body
742		let request = if let Some(body_bytes) = body {
743			req_builder.body(Full::new(body_bytes))?
744		} else {
745			req_builder.body(Full::new(Bytes::new()))?
746		};
747
748		// Execute request
749		let response = if let Some(handler) = &self.handler {
750			// Use custom handler if set
751			handler(request)
752		} else {
753			// Use reqwest for real HTTP requests when no handler is set
754			let (parts, body) = request.into_parts();
755
756			// Build reqwest request
757			let url = if parts.uri.scheme_str().is_some() {
758				// Absolute URL
759				parts.uri.to_string()
760			} else {
761				// Relative path - use base_url
762				format!(
763					"{}{}",
764					self.base_url.trim_end_matches('/'),
765					parts.uri.path()
766				)
767			};
768
769			// Use the stored http_client (connection pooling enabled)
770			let mut reqwest_request = self.http_client.request(
771				reqwest::Method::from_bytes(parts.method.as_str().as_bytes()).unwrap(),
772				&url,
773			);
774
775			// Copy headers (skip Cookie if using cookie_store, as reqwest manages it automatically)
776			for (name, value) in parts.headers.iter() {
777				if self.use_cookie_store && name.as_str().eq_ignore_ascii_case("cookie") {
778					continue;
779				}
780				reqwest_request = reqwest_request.header(name.as_str(), value.as_bytes());
781			}
782
783			// Copy body
784			let body_bytes = body
785				.collect()
786				.await
787				.map(|c| c.to_bytes())
788				.unwrap_or_else(|_| Bytes::new());
789			if !body_bytes.is_empty() {
790				reqwest_request = reqwest_request.body(body_bytes.to_vec());
791			}
792
793			// Execute reqwest request
794			let reqwest_response = reqwest_request.send().await?;
795
796			// Convert reqwest response to http::Response
797			let status = reqwest_response.status();
798			let version = reqwest_response.version();
799			let headers = reqwest_response.headers().clone();
800			let body_bytes = reqwest_response.bytes().await?;
801
802			let mut response_builder = Response::builder().status(status).version(version);
803			for (name, value) in headers.iter() {
804				response_builder = response_builder.header(name, value);
805			}
806
807			response_builder.body(Full::new(body_bytes))?
808		};
809
810		// Extract body from response using async collection
811		let (parts, response_body) = response.into_parts();
812		let body_data = response_body
813			.collect()
814			.await
815			.map(|collected| collected.to_bytes())
816			.unwrap_or_else(|_| Bytes::new());
817
818		Ok(TestResponse::with_body_and_version(
819			parts.status,
820			parts.headers,
821			body_data,
822			parts.version,
823		))
824	}
825
826	/// Serialize data based on format
827	fn serialize_data<T: Serialize>(&self, data: &T, format: &str) -> ClientResult<Bytes> {
828		match format {
829			"json" => {
830				let json = serde_json::to_vec(data)?;
831				Ok(Bytes::from(json))
832			}
833			"form" => {
834				// URL-encoded form data
835				let json_value = serde_json::to_value(data)?;
836				if let Value::Object(map) = json_value {
837					let form_data = map
838						.iter()
839						.map(|(k, v)| {
840							let value_str = match v {
841								Value::String(s) => s.clone(),
842								_ => v.to_string(),
843							};
844							format!(
845								"{}={}",
846								urlencoding::encode(k),
847								urlencoding::encode(&value_str)
848							)
849						})
850						.collect::<Vec<_>>()
851						.join("&");
852					Ok(Bytes::from(form_data))
853				} else {
854					Err(ClientError::RequestFailed(
855						"Expected object for form data".to_string(),
856					))
857				}
858			}
859			_ => Err(ClientError::RequestFailed(format!(
860				"Unsupported format: {}",
861				format
862			))),
863		}
864	}
865
866	/// Get content type for format
867	fn get_content_type(&self, format: &str) -> &str {
868		match format {
869			"json" => "application/json",
870			"form" => "application/x-www-form-urlencoded",
871			_ => "application/octet-stream",
872		}
873	}
874}
875
876/// Validate a cookie key to prevent header injection attacks.
877///
878/// Cookie keys must not contain `=`, `;`, whitespace, or control characters.
879///
880/// # Panics
881///
882/// Panics if the cookie key contains invalid characters.
883fn validate_cookie_key(key: &str) {
884	assert!(!key.is_empty(), "cookie key must not be empty");
885	assert!(
886		!key.contains('='),
887		"cookie key must not contain '=' (found in key: {:?})",
888		key
889	);
890	assert!(
891		!key.contains(';'),
892		"cookie key must not contain ';' (found in key: {:?})",
893		key
894	);
895	assert!(
896		!key.chars().any(|c| c.is_ascii_whitespace()),
897		"cookie key must not contain whitespace (found in key: {:?})",
898		key
899	);
900	assert!(
901		!key.chars().any(|c| c.is_control()),
902		"cookie key must not contain control characters (found in key: {:?})",
903		key
904	);
905}
906
907/// Validate a cookie value to prevent header injection attacks.
908///
909/// Cookie values must not contain `;`, newlines (`\r`, `\n`), or control characters.
910///
911/// # Panics
912///
913/// Panics if the cookie value contains invalid characters.
914fn validate_cookie_value(value: &str) {
915	assert!(
916		!value.contains(';'),
917		"cookie value must not contain ';' (found in value: {:?})",
918		value
919	);
920	assert!(
921		!value.contains('\r') && !value.contains('\n'),
922		"cookie value must not contain newlines (found in value: {:?})",
923		value
924	);
925	assert!(
926		!value.chars().any(|c| c.is_control()),
927		"cookie value must not contain control characters (found in value: {:?})",
928		value
929	);
930}
931
932impl Default for APIClient {
933	fn default() -> Self {
934		Self::new()
935	}
936}
937
938// Need to add base64 dependency
939mod base64 {
940	pub(super) fn encode(input: String) -> String {
941		// Simple base64 encoding (in production, use a proper library)
942		use base64_simd::STANDARD;
943		STANDARD.encode_to_string(input.as_bytes())
944	}
945}
946
947// Need to add urlencoding
948mod urlencoding {
949	pub(super) fn encode(input: &str) -> String {
950		url::form_urlencoded::byte_serialize(input.as_bytes()).collect()
951	}
952}
953
954#[cfg(test)]
955mod tests {
956	use super::*;
957	use rstest::rstest;
958
959	#[rstest]
960	fn test_validate_cookie_key_accepts_valid_key() {
961		// Arrange
962		let key = "session_id";
963
964		// Act & Assert (should not panic)
965		validate_cookie_key(key);
966	}
967
968	#[rstest]
969	#[should_panic(expected = "must not be empty")]
970	fn test_validate_cookie_key_rejects_empty() {
971		// Arrange
972		let key = "";
973
974		// Act
975		validate_cookie_key(key);
976	}
977
978	#[rstest]
979	#[should_panic(expected = "must not contain '='")]
980	fn test_validate_cookie_key_rejects_equals_sign() {
981		// Arrange
982		let key = "key=value";
983
984		// Act
985		validate_cookie_key(key);
986	}
987
988	#[rstest]
989	#[should_panic(expected = "must not contain ';'")]
990	fn test_validate_cookie_key_rejects_semicolon() {
991		// Arrange
992		let key = "key;injection";
993
994		// Act
995		validate_cookie_key(key);
996	}
997
998	#[rstest]
999	#[should_panic(expected = "must not contain whitespace")]
1000	fn test_validate_cookie_key_rejects_whitespace() {
1001		// Arrange
1002		let key = "key name";
1003
1004		// Act
1005		validate_cookie_key(key);
1006	}
1007
1008	#[rstest]
1009	#[should_panic(expected = "must not contain control characters")]
1010	fn test_validate_cookie_key_rejects_control_chars() {
1011		// Arrange
1012		let key = "key\x00name";
1013
1014		// Act
1015		validate_cookie_key(key);
1016	}
1017
1018	#[rstest]
1019	fn test_validate_cookie_value_accepts_valid_value() {
1020		// Arrange
1021		let value = "abc123-token";
1022
1023		// Act & Assert (should not panic)
1024		validate_cookie_value(value);
1025	}
1026
1027	#[rstest]
1028	fn test_validate_cookie_value_accepts_empty() {
1029		// Arrange
1030		let value = "";
1031
1032		// Act & Assert (should not panic)
1033		validate_cookie_value(value);
1034	}
1035
1036	#[rstest]
1037	#[should_panic(expected = "must not contain ';'")]
1038	fn test_validate_cookie_value_rejects_semicolon() {
1039		// Arrange
1040		let value = "value; extra=injected";
1041
1042		// Act
1043		validate_cookie_value(value);
1044	}
1045
1046	#[rstest]
1047	#[should_panic(expected = "must not contain newlines")]
1048	fn test_validate_cookie_value_rejects_newline() {
1049		// Arrange
1050		let value = "value\r\nInjected-Header: malicious";
1051
1052		// Act
1053		validate_cookie_value(value);
1054	}
1055
1056	#[rstest]
1057	#[should_panic(expected = "must not contain control characters")]
1058	fn test_validate_cookie_value_rejects_control_chars() {
1059		// Arrange
1060		let value = "value\x01hidden";
1061
1062		// Act
1063		validate_cookie_value(value);
1064	}
1065
1066	#[rstest]
1067	#[should_panic(expected = "must not contain newlines")]
1068	fn test_validate_cookie_value_rejects_lf_only() {
1069		// Arrange
1070		let value = "value\nInjected-Header: evil";
1071
1072		// Act
1073		validate_cookie_value(value);
1074	}
1075}