Skip to main content

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}