clawspec_core/client/response/redaction/request_body.rs
1//! Request body redaction support for OpenAPI documentation.
2//!
3//! This module provides functionality to redact sensitive values in JSON request bodies
4//! before they are stored as examples in the OpenAPI specification. The key principle is:
5//!
6//! - **Original value for HTTP**: The actual serialized data is sent in the HTTP request
7//! - **Redacted value for OpenAPI**: The redacted value is used for documentation examples
8//!
9//! This allows you to test with real data while keeping your OpenAPI examples clean,
10//! stable, and free of sensitive information.
11//!
12//! # Path Syntax
13//!
14//! The path syntax is auto-detected based on the prefix:
15//! - Paths starting with `$` use JSONPath (RFC 9535) - supports wildcards
16//! - Paths starting with `/` use JSON Pointer (RFC 6901) - exact paths only
17//!
18//! # Example
19//!
20//! ```ignore
21//! use clawspec_core::ApiClient;
22//! use serde::Serialize;
23//! use utoipa::ToSchema;
24//!
25//! #[derive(Clone, Serialize, ToSchema)]
26//! struct CreateUser {
27//! username: String,
28//! password: String,
29//! api_key: String,
30//! }
31//!
32//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
33//! let mut client = ApiClient::builder().build()?;
34//!
35//! let user = CreateUser {
36//! username: "alice".to_string(),
37//! password: "secret123".to_string(),
38//! api_key: "sk-live-abc123".to_string(),
39//! };
40//!
41//! // The HTTP request will contain the real password and API key,
42//! // but the OpenAPI example will show the redacted values.
43//! client
44//! .post("/users")?
45//! .json_redacted(&user)?
46//! .redact("/password", "[REDACTED]")?
47//! .redact("/api_key", "[REDACTED]")?
48//! .await?; // IntoFuture - no .finish() needed
49//! # Ok(())
50//! # }
51//! ```
52
53use std::future::{Future, IntoFuture};
54use std::pin::Pin;
55
56use utoipa::ToSchema;
57
58use super::RedactOptions;
59use super::apply::{apply_redaction, apply_remove};
60use super::redactor::Redactor;
61use crate::client::call::ApiCall;
62use crate::client::error::ApiClientError;
63use crate::client::{CallBody, CallResult};
64
65/// Builder for redacting sensitive values in JSON request bodies.
66///
67/// This builder allows you to apply redactions to a JSON request body before
68/// it's used in the OpenAPI documentation. The original (unredacted) value
69/// is sent in the actual HTTP request.
70///
71/// # Key Principle
72///
73/// - **HTTP Request**: Uses the original value with real data for testing
74/// - **OpenAPI Example**: Uses the redacted value with stable placeholders
75///
76/// This separation allows you to:
77/// - Test with realistic data (passwords, tokens, API keys)
78/// - Generate stable OpenAPI documentation (no dynamic values)
79/// - Hide sensitive information from documentation
80///
81/// # Path Syntax
82///
83/// Paths are auto-detected based on their prefix:
84/// - `/...` → JSON Pointer (RFC 6901) for exact paths
85/// - `$...` → JSONPath (RFC 9535) for wildcards
86///
87/// # Example
88///
89/// ```ignore
90/// use clawspec_core::ApiClient;
91/// use serde::Serialize;
92/// use utoipa::ToSchema;
93///
94/// #[derive(Clone, Serialize, ToSchema)]
95/// struct LoginRequest {
96/// email: String,
97/// password: String,
98/// }
99///
100/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
101/// let mut client = ApiClient::builder().build()?;
102///
103/// let request = LoginRequest {
104/// email: "user@example.com".to_string(),
105/// password: "my-secret-password".to_string(),
106/// };
107///
108/// client
109/// .post("/auth/login")?
110/// .json_redacted(&request)?
111/// .redact("/password", "[REDACTED]")?
112/// .await?; // IntoFuture - no .finish() needed
113/// # Ok(())
114/// # }
115/// ```
116#[derive(derive_more::Debug)]
117#[cfg_attr(docsrs, doc(cfg(feature = "redaction")))]
118pub struct RequestBodyRedactionBuilder<T> {
119 /// The original value (kept for reference, used in HTTP request via body.data)
120 #[debug(skip)]
121 value: T,
122 /// The JSON representation for redaction operations
123 redacted: serde_json::Value,
124 /// The body being built (contains serialized data for HTTP)
125 body: CallBody,
126 /// The ApiCall to return when finished
127 #[debug(skip)]
128 api_call: ApiCall,
129}
130
131impl<T> RequestBodyRedactionBuilder<T> {
132 /// Creates a new request body redaction builder.
133 pub(crate) fn new(
134 value: T,
135 redacted: serde_json::Value,
136 body: CallBody,
137 api_call: ApiCall,
138 ) -> Self {
139 Self {
140 value,
141 redacted,
142 body,
143 api_call,
144 }
145 }
146
147 /// Redacts values at the specified path using a redactor.
148 ///
149 /// The path can be either JSON Pointer (RFC 6901) or JSONPath (RFC 9535).
150 /// The syntax is auto-detected based on the prefix:
151 /// - `$...` → JSONPath (supports wildcards)
152 /// - `/...` → JSON Pointer (exact path)
153 ///
154 /// The redactor can be:
155 /// - A static value: `"replacement"` or `serde_json::json!(...)`
156 /// - A closure: `|path, val| transform(path, val)`
157 ///
158 /// # Arguments
159 ///
160 /// * `path` - Path expression (e.g., `/password`, `$.users[*].token`)
161 /// * `redactor` - The redactor to apply (static value or closure)
162 ///
163 /// # Errors
164 ///
165 /// Returns an error if:
166 /// - The path is invalid
167 /// - The path matches no values
168 ///
169 /// # Example
170 ///
171 /// ```ignore
172 /// # use clawspec_core::ApiClient;
173 /// # use serde::Serialize;
174 /// # use utoipa::ToSchema;
175 /// # #[derive(Clone, Serialize, ToSchema)]
176 /// # struct Request { token: String }
177 /// # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
178 /// // Static value
179 /// client.post("/api")?
180 /// .json_redacted(&Request { token: "secret".into() })?
181 /// .redact("/token", "[REDACTED]")?
182 /// .await?; // IntoFuture - no .finish() needed
183 /// # Ok(())
184 /// # }
185 /// ```
186 pub fn redact<R: Redactor>(self, path: &str, redactor: R) -> Result<Self, ApiClientError> {
187 self.redact_with_options(path, redactor, RedactOptions::default())
188 }
189
190 /// Redacts values at the specified path with configurable options.
191 ///
192 /// This is like [`redact`](Self::redact) but allows customizing
193 /// behavior through [`RedactOptions`].
194 ///
195 /// # Arguments
196 ///
197 /// * `path` - Path expression (e.g., `/password`, `$.users[*].token`)
198 /// * `redactor` - The redactor to apply
199 /// * `options` - Configuration options
200 ///
201 /// # Example
202 ///
203 /// ```ignore
204 /// use clawspec_core::RedactOptions;
205 ///
206 /// // Allow empty matches for optional fields
207 /// let options = RedactOptions { allow_empty_match: true };
208 ///
209 /// builder
210 /// .redact_with_options("$.optional_field", "value", options)?
211 /// .await?; // IntoFuture - no .finish() needed
212 /// ```
213 pub fn redact_with_options<R: Redactor>(
214 mut self,
215 path: &str,
216 redactor: R,
217 options: RedactOptions,
218 ) -> Result<Self, ApiClientError> {
219 apply_redaction(&mut self.redacted, path, redactor, options)?;
220 Ok(self)
221 }
222
223 /// Removes values at the specified path from the OpenAPI example.
224 ///
225 /// This completely removes the field from the OpenAPI documentation example,
226 /// unlike setting it to `null`. The original value is still sent in the HTTP request.
227 ///
228 /// The path can be either JSON Pointer (RFC 6901) or JSONPath (RFC 9535).
229 ///
230 /// # Arguments
231 ///
232 /// * `path` - Path expression to remove
233 ///
234 /// # Errors
235 ///
236 /// Returns an error if:
237 /// - The path is invalid
238 /// - The path matches no values
239 ///
240 /// # Example
241 ///
242 /// ```ignore
243 /// # use clawspec_core::ApiClient;
244 /// # use serde::Serialize;
245 /// # use utoipa::ToSchema;
246 /// # #[derive(Clone, Serialize, ToSchema)]
247 /// # struct Request { password: String, internal_id: String }
248 /// # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
249 /// client.post("/api")?
250 /// .json_redacted(&Request {
251 /// password: "secret".into(),
252 /// internal_id: "internal-123".into(),
253 /// })?
254 /// .redact("/password", "[REDACTED]")?
255 /// .redact_remove("/internal_id")? // Remove entirely from docs
256 /// .await?; // IntoFuture - no .finish() needed
257 /// # Ok(())
258 /// # }
259 /// ```
260 pub fn redact_remove(self, path: &str) -> Result<Self, ApiClientError> {
261 self.redact_remove_with(path, RedactOptions::default())
262 }
263
264 /// Removes values at the specified path with configurable options.
265 ///
266 /// This is like [`redact_remove`](Self::redact_remove) but allows customizing
267 /// behavior through [`RedactOptions`].
268 ///
269 /// # Arguments
270 ///
271 /// * `path` - Path expression to remove
272 /// * `options` - Configuration options
273 ///
274 /// # Example
275 ///
276 /// ```ignore
277 /// use clawspec_core::RedactOptions;
278 ///
279 /// // Allow empty matches for optional fields
280 /// let options = RedactOptions { allow_empty_match: true };
281 ///
282 /// builder
283 /// .redact_remove_with("$.optional_field", options)?
284 /// .await?; // IntoFuture - no .finish() needed
285 /// ```
286 pub fn redact_remove_with(
287 mut self,
288 path: &str,
289 options: RedactOptions,
290 ) -> Result<Self, ApiClientError> {
291 apply_remove(&mut self.redacted, path, options)?;
292 Ok(self)
293 }
294
295 /// Finalizes the redaction and returns the configured ApiCall.
296 ///
297 /// This consumes the builder and returns the `ApiCall` with the request body
298 /// configured. The body will contain:
299 /// - **HTTP data**: The original (unredacted) serialized value
300 /// - **OpenAPI example**: The redacted value for documentation
301 ///
302 /// After calling `finish()`, you can `.await` the `ApiCall` to execute
303 /// the HTTP request.
304 ///
305 /// # Example
306 ///
307 /// ```ignore
308 /// # use clawspec_core::ApiClient;
309 /// # use serde::Serialize;
310 /// # use utoipa::ToSchema;
311 /// # #[derive(Clone, Serialize, ToSchema)]
312 /// # struct Request { password: String }
313 /// # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
314 /// let response = client
315 /// .post("/api")?
316 /// .json_redacted(&Request { password: "secret".into() })?
317 /// .redact("/password", "[REDACTED]")?
318 /// .finish()? // Returns ApiCall
319 /// .await?; // Executes the HTTP request
320 /// # Ok(())
321 /// # }
322 /// ```
323 pub fn finish(mut self) -> Result<ApiCall, ApiClientError>
324 where
325 T: ToSchema + 'static,
326 {
327 // Set the redacted example on the body
328 self.body.set_example(self.redacted);
329
330 // Set the body on the ApiCall
331 self.api_call.body = Some(self.body);
332
333 Ok(self.api_call)
334 }
335
336 /// Returns a reference to the original (unredacted) value.
337 ///
338 /// This can be useful if you need to inspect the original value
339 /// while building the redactions.
340 pub fn original_value(&self) -> &T {
341 &self.value
342 }
343
344 /// Returns a reference to the current redacted JSON value.
345 ///
346 /// This can be useful if you need to inspect the redacted state
347 /// while building the redactions.
348 pub fn redacted_value(&self) -> &serde_json::Value {
349 &self.redacted
350 }
351}
352
353/// Implements `IntoFuture` to allow direct `.await` on the builder.
354///
355/// This enables a more ergonomic API where you can write:
356///
357/// ```ignore
358/// client
359/// .post("/users")?
360/// .json_redacted(&user)?
361/// .redact("/password", "[REDACTED]")?
362/// .await?; // No need for .finish()?
363/// ```
364///
365/// Instead of:
366///
367/// ```ignore
368/// client
369/// .post("/users")?
370/// .json_redacted(&user)?
371/// .redact("/password", "[REDACTED]")?
372/// .finish()?
373/// .await?;
374/// ```
375impl<T> IntoFuture for RequestBodyRedactionBuilder<T>
376where
377 T: ToSchema + 'static,
378{
379 type Output = Result<CallResult, ApiClientError>;
380 type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
381
382 fn into_future(self) -> Self::IntoFuture {
383 // Call finish() synchronously to avoid capturing self in the async block
384 match self.finish() {
385 Ok(api_call) => api_call.into_future(),
386 Err(e) => Box::pin(async move { Err(e) }),
387 }
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use serde::{Deserialize, Serialize};
394 use serde_json::json;
395 use utoipa::ToSchema;
396
397 use super::*;
398
399 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
400 struct TestRequest {
401 username: String,
402 password: String,
403 api_key: String,
404 }
405
406 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
407 struct NestedRequest {
408 user: UserInfo,
409 items: Vec<Item>,
410 }
411
412 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
413 struct UserInfo {
414 id: String,
415 token: String,
416 }
417
418 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
419 struct Item {
420 id: String,
421 secret: String,
422 }
423
424 /// Creates a minimal ApiCall for testing purposes.
425 fn create_test_api_call() -> ApiCall {
426 let client = reqwest::Client::new();
427 let base_uri = "http://localhost:8080".parse().expect("valid URI");
428 let collector_sender = crate::client::openapi::channel::CollectorSender::dummy();
429 let path = crate::client::CallPath::from("/test");
430 let query = crate::client::CallQuery::new();
431 let expected_status_codes = crate::client::response::ExpectedStatusCodes::default();
432 let metadata = crate::client::call_parameters::OperationMetadata::default();
433
434 ApiCall {
435 client,
436 base_uri,
437 collector_sender,
438 method: http::Method::POST,
439 path,
440 query,
441 headers: None,
442 body: None,
443 authentication: None,
444 cookies: None,
445 expected_status_codes,
446 metadata,
447 response_description: None,
448 skip_collection: false,
449 security: None,
450 }
451 }
452
453 fn create_test_builder() -> RequestBodyRedactionBuilder<TestRequest> {
454 let value = TestRequest {
455 username: "alice".to_string(),
456 password: "secret123".to_string(),
457 api_key: "sk-live-abc123".to_string(),
458 };
459
460 let redacted = json!({
461 "username": "alice",
462 "password": "secret123",
463 "api_key": "sk-live-abc123"
464 });
465
466 let body = CallBody::json_without_example(&value).expect("should create body");
467 let api_call = create_test_api_call();
468
469 RequestBodyRedactionBuilder::new(value, redacted, body, api_call)
470 }
471
472 fn create_nested_builder() -> RequestBodyRedactionBuilder<NestedRequest> {
473 let value = NestedRequest {
474 user: UserInfo {
475 id: "user-123".to_string(),
476 token: "token-abc".to_string(),
477 },
478 items: vec![
479 Item {
480 id: "item-1".to_string(),
481 secret: "secret-1".to_string(),
482 },
483 Item {
484 id: "item-2".to_string(),
485 secret: "secret-2".to_string(),
486 },
487 ],
488 };
489
490 let redacted = serde_json::to_value(&value).expect("should serialize");
491 let body = CallBody::json_without_example(&value).expect("should create body");
492 let api_call = create_test_api_call();
493
494 RequestBodyRedactionBuilder::new(value, redacted, body, api_call)
495 }
496
497 #[test]
498 fn should_redact_single_field() {
499 let builder = create_test_builder()
500 .redact("/password", "[REDACTED]")
501 .expect("redaction should succeed");
502
503 assert_eq!(
504 builder.redacted.get("password").and_then(|v| v.as_str()),
505 Some("[REDACTED]")
506 );
507 assert_eq!(
508 builder.redacted.get("username").and_then(|v| v.as_str()),
509 Some("alice")
510 );
511 assert_eq!(
512 builder.redacted.get("api_key").and_then(|v| v.as_str()),
513 Some("sk-live-abc123")
514 );
515 }
516
517 #[test]
518 fn should_redact_multiple_fields() {
519 let builder = create_test_builder()
520 .redact("/password", "[REDACTED]")
521 .and_then(|b| b.redact("/api_key", "[REDACTED]"))
522 .expect("redaction should succeed");
523
524 assert_eq!(
525 builder.redacted.get("password").and_then(|v| v.as_str()),
526 Some("[REDACTED]")
527 );
528 assert_eq!(
529 builder.redacted.get("api_key").and_then(|v| v.as_str()),
530 Some("[REDACTED]")
531 );
532 assert_eq!(
533 builder.redacted.get("username").and_then(|v| v.as_str()),
534 Some("alice")
535 );
536 }
537
538 #[test]
539 fn should_redact_with_jsonpath_wildcards() {
540 let builder = create_nested_builder()
541 .redact("$.items[*].secret", "[REDACTED]")
542 .expect("redaction should succeed");
543
544 let items = builder
545 .redacted
546 .get("items")
547 .and_then(|v| v.as_array())
548 .expect("should have items");
549
550 for item in items {
551 assert_eq!(
552 item.get("secret").and_then(|v| v.as_str()),
553 Some("[REDACTED]")
554 );
555 }
556 }
557
558 #[test]
559 fn should_redact_with_closure() {
560 let builder = create_test_builder()
561 .redact("/password", |_path: &str, _val: &serde_json::Value| {
562 json!("redacted-by-closure")
563 })
564 .expect("redaction should succeed");
565
566 assert_eq!(
567 builder.redacted.get("password").and_then(|v| v.as_str()),
568 Some("redacted-by-closure")
569 );
570 }
571
572 #[test]
573 fn should_remove_fields() {
574 let builder = create_test_builder()
575 .redact_remove("/password")
576 .expect("removal should succeed");
577
578 assert!(builder.redacted.get("password").is_none());
579 assert!(builder.redacted.get("username").is_some());
580 assert!(builder.redacted.get("api_key").is_some());
581 }
582
583 #[test]
584 fn should_preserve_original_value() {
585 let builder = create_test_builder()
586 .redact("/password", "[REDACTED]")
587 .expect("redaction should succeed");
588
589 assert_eq!(builder.original_value().password, "secret123");
590 assert_eq!(
591 builder.redacted.get("password").and_then(|v| v.as_str()),
592 Some("[REDACTED]")
593 );
594 }
595
596 #[test]
597 fn should_fail_on_invalid_path() {
598 let result = create_test_builder().redact("$.nonexistent", "[REDACTED]");
599
600 assert!(result.is_err());
601 }
602
603 #[test]
604 fn should_allow_empty_match_with_option() {
605 let options = RedactOptions {
606 allow_empty_match: true,
607 };
608 let result =
609 create_test_builder().redact_with_options("$.nonexistent", "[REDACTED]", options);
610
611 assert!(result.is_ok());
612 }
613
614 #[test]
615 fn should_access_redacted_value() {
616 let builder = create_test_builder();
617
618 assert_eq!(
619 builder
620 .redacted_value()
621 .get("password")
622 .and_then(|v| v.as_str()),
623 Some("secret123")
624 );
625
626 let builder = builder
627 .redact("/password", "[REDACTED]")
628 .expect("should redact");
629
630 assert_eq!(
631 builder
632 .redacted_value()
633 .get("password")
634 .and_then(|v| v.as_str()),
635 Some("[REDACTED]")
636 );
637 }
638
639 #[test]
640 fn should_finish_and_return_api_call() {
641 let api_call = create_test_builder()
642 .redact("/password", "[REDACTED]")
643 .and_then(|b| b.finish())
644 .expect("should finish");
645
646 assert!(api_call.body.is_some());
647 }
648
649 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
650 struct LoginRequest {
651 username: String,
652 password: String,
653 }
654
655 fn get_request_body_example(
656 openapi: &utoipa::openapi::OpenApi,
657 path: &str,
658 method: &str,
659 ) -> Option<serde_json::Value> {
660 let path_item = openapi.paths.paths.get(path)?;
661 let operation = match method.to_uppercase().as_str() {
662 "POST" => path_item.post.as_ref(),
663 "PUT" => path_item.put.as_ref(),
664 "PATCH" => path_item.patch.as_ref(),
665 "DELETE" => path_item.delete.as_ref(),
666 "GET" => path_item.get.as_ref(),
667 _ => None,
668 }?;
669 let request_body = operation.request_body.as_ref()?;
670 let content = request_body.content.get("application/json")?;
671 content.example.clone()
672 }
673
674 #[tokio::test]
675 async fn should_send_original_value_to_server_and_use_redacted_in_openapi() {
676 use wiremock::matchers::{body_json, method, path};
677 use wiremock::{Mock, MockServer, ResponseTemplate};
678
679 use crate::client::ApiClient;
680
681 // 1. Start mock server
682 let mock_server = MockServer::start().await;
683
684 // 2. Set up mock to capture and verify request body
685 // The mock expects the ORIGINAL (unredacted) value
686 Mock::given(method("POST"))
687 .and(path("/api/login"))
688 .and(body_json(json!({
689 "username": "alice",
690 "password": "secret123"
691 })))
692 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"status": "ok"})))
693 .expect(1)
694 .mount(&mock_server)
695 .await;
696
697 // 3. Create ApiClient pointing to mock server
698 let uri: http::Uri = mock_server.uri().parse().expect("valid URI");
699 let mut client = ApiClient::builder()
700 .with_host(uri.host().expect("should have host"))
701 .with_port(uri.port_u16().expect("should have port"))
702 .build()
703 .expect("should build client");
704
705 // 4. Make request with redaction
706 let request = LoginRequest {
707 username: "alice".to_string(),
708 password: "secret123".to_string(),
709 };
710
711 client
712 .post("/api/login")
713 .expect("should create call")
714 .json_redacted(&request)
715 .expect("should set body")
716 .redact("/password", "[REDACTED]")
717 .expect("should redact")
718 .await
719 .expect("request should succeed")
720 .as_empty()
721 .await
722 .expect("should complete");
723
724 // 5. Verify mock received the original value (implicit via matcher)
725 // wiremock will fail if body doesn't match
726
727 // 6. Verify OpenAPI example contains redacted value
728 let openapi = client.collected_openapi().await;
729 let example = get_request_body_example(&openapi, "/api/login", "POST")
730 .expect("should have request body example");
731
732 assert_eq!(
733 example.get("password").and_then(|v| v.as_str()),
734 Some("[REDACTED]"),
735 "OpenAPI example should have redacted password"
736 );
737 assert_eq!(
738 example.get("username").and_then(|v| v.as_str()),
739 Some("alice"),
740 "OpenAPI example should have original username"
741 );
742 }
743}