Skip to main content

clawspec_core/client/response/redaction/
mod.rs

1//! JSON response redaction support using JSON Pointer (RFC 6901) and JSONPath (RFC 9535).
2//!
3//! This module provides functionality to redact sensitive or dynamic values
4//! in JSON responses for snapshot testing. It allows you to replace or remove
5//! values at specific paths while preserving the original data for test assertions.
6//!
7//! # Path Syntax
8//!
9//! The path syntax is auto-detected based on the prefix:
10//! - Paths starting with `$` use JSONPath (RFC 9535) - supports wildcards
11//! - Paths starting with `/` use JSON Pointer (RFC 6901) - exact paths only
12//!
13//! ## JSONPath Examples (wildcards)
14//!
15//! - `$.items[*].id` - all `id` fields in the `items` array
16//! - `$..id` - all `id` fields anywhere in the document (recursive descent)
17//! - `$.users[0:3].email` - `email` in first 3 users
18//!
19//! ## JSON Pointer Examples (exact paths)
20//!
21//! - `/id` - top-level `id` field
22//! - `/user/email` - nested field
23//! - `/items/0/id` - specific array index
24//!
25//! # Redactor Types
26//!
27//! The [`redact`](RedactionBuilder::redact) method accepts any type implementing [`Redactor`]:
28//!
29//! - **Static values**: `&str`, `String`, `serde_json::Value`
30//! - **Functions**: `Fn(&str, &Value) -> Value` - transform based on path and/or value
31//!
32//! # Example
33//!
34//! ```ignore
35//! use clawspec_core::ApiClient;
36//! use serde::{Deserialize, Serialize};
37//!
38//! #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
39//! struct User {
40//!     id: String,
41//!     name: String,
42//!     created_at: String,
43//! }
44//!
45//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
46//! let client = ApiClient::builder().with_base_path("http://localhost:8080".parse()?).build()?;
47//!
48//! // Using static values
49//! let result = client
50//!     .get("/api/users/123")?
51//!     .await?
52//!     .as_json_redacted::<User>().await?
53//!     .redact("/id", "stable-uuid")?
54//!     .redact("/created_at", "2024-01-01T00:00:00Z")?
55//!     .finish()
56//!     .await;
57//!
58//! // Using JSONPath wildcards with static values
59//! let result = client
60//!     .get("/api/users")?
61//!     .await?
62//!     .as_json_redacted::<Vec<User>>().await?
63//!     .redact("$[*].id", "redacted-uuid")?
64//!     .redact("$[*].created_at", "2024-01-01T00:00:00Z")?
65//!     .finish()
66//!     .await;
67//!
68//! // Using closure for index-based IDs
69//! let result = client
70//!     .get("/api/users")?
71//!     .await?
72//!     .as_json_redacted::<Vec<User>>().await?
73//!     .redact("$[*].id", |path, _val| {
74//!         let idx = path.split('/').nth(1).unwrap_or("0");
75//!         serde_json::json!(format!("user-{idx}"))
76//!     })?
77//!     .finish()
78//!     .await;
79//!
80//! // Test assertions use the real value
81//! assert!(!result.value.is_empty());
82//!
83//! // Snapshots use the redacted value (stable ids and timestamps)
84//! insta::assert_yaml_snapshot!(result.redacted);
85//! # Ok(())
86//! # }
87//! ```
88
89use std::any::{TypeId, type_name};
90
91use headers::ContentType;
92use http::StatusCode;
93use serde::de::DeserializeOwned;
94use serde_json::Deserializer;
95use utoipa::ToSchema;
96use utoipa::openapi::{RefOr, Schema};
97
98use super::output::Output;
99
100mod apply;
101mod path_selector;
102mod redactor;
103mod request_body;
104mod value_builder;
105
106pub use self::redactor::Redactor;
107pub use self::request_body::RequestBodyRedactionBuilder;
108pub use self::value_builder::ValueRedactionBuilder;
109use crate::client::CallResult;
110use crate::client::error::ApiClientError;
111use crate::client::openapi::channel::{CollectorMessage, CollectorSender};
112use crate::client::openapi::schema::{SchemaEntry, compute_schema_ref};
113
114impl CallResult {
115    /// Deserializes the JSON response and returns a builder for applying redactions.
116    ///
117    /// This method is similar to [`as_json()`](CallResult::as_json) but returns a
118    /// [`RedactionBuilder`](super::redaction::RedactionBuilder) that allows you to apply redactions
119    /// to the JSON before finalizing the result.
120    ///
121    /// The original value is preserved for test assertions, while the redacted
122    /// JSON can be used for snapshot testing with stable values.
123    ///
124    /// # Type Parameters
125    ///
126    /// * `T` - The type to deserialize into. Must implement [`DeserializeOwned`],
127    ///   [`ToSchema`], and have a `'static` lifetime.
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if:
132    /// - The response is not JSON
133    /// - JSON deserialization fails
134    ///
135    /// # Example
136    ///
137    /// ```ignore
138    /// # use clawspec_core::ApiClient;
139    /// # use serde::{Deserialize, Serialize};
140    /// # #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
141    /// # struct User { id: String, name: String }
142    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
143    /// # let client = ApiClient::builder().with_base_path("http://localhost".parse()?).build()?;
144    /// let result = client
145    ///     .get("/api/users/123")?
146    ///     .await?
147    ///     .as_json_redacted::<User>().await?
148    ///     .redact("/id", "stable-uuid")?
149    ///     .finish()
150    ///     .await;
151    ///
152    /// // Use real value for assertions
153    /// assert!(!result.value.id.is_empty());
154    ///
155    /// // Use redacted value for snapshots
156    /// insta::assert_yaml_snapshot!(result.redacted);
157    /// # Ok(())
158    /// # }
159    /// ```
160    pub async fn as_json_redacted<T>(&mut self) -> Result<RedactionBuilder<T>, ApiClientError>
161    where
162        T: DeserializeOwned + ToSchema + 'static,
163    {
164        // Compute schema reference locally (no lock needed)
165        let schema = compute_schema_ref::<T>();
166
167        // Register the schema entry via channel
168        let entry = SchemaEntry::of::<T>();
169        self.collector_sender
170            .send(CollectorMessage::AddSchemaEntry(entry))
171            .await;
172
173        // Access output directly without calling get_output() to defer response registration
174        let Output::Json(json) = self.output() else {
175            return Err(ApiClientError::UnsupportedJsonOutput {
176                output: self.output().clone(),
177                name: type_name::<T>(),
178            });
179        };
180
181        // Delegate to redaction module with deferred registration data
182        // Response will be registered in finish() with the redacted example
183        let builder = super::redaction::create_redaction_builder::<T>(
184            json,
185            self.collector_sender.clone(),
186            self.operation_id().to_string(),
187            self.status(),
188            self.content_type().cloned(),
189            schema,
190        )?;
191
192        Ok(builder)
193    }
194}
195
196/// Result of a redacted JSON response containing both the real and redacted values.
197///
198/// This struct is returned by [`CallResult::as_json_redacted()`] and provides
199/// access to both the original deserialized value and the redacted JSON for
200/// snapshot testing.
201#[derive(Debug, Clone)]
202#[cfg_attr(docsrs, doc(cfg(feature = "redaction")))]
203pub struct RedactedResult<T> {
204    /// The real deserialized value for test assertions.
205    pub value: T,
206    /// The redacted JSON value for snapshot testing.
207    pub redacted: serde_json::Value,
208}
209
210/// Options for configuring redaction behavior.
211///
212/// Use this struct with [`RedactionBuilder::redact_with_options`] and
213/// [`RedactionBuilder::redact_remove_with`] to customize how redaction
214/// handles edge cases.
215///
216/// # Example
217///
218/// ```ignore
219/// use clawspec_core::RedactOptions;
220///
221/// // Allow empty matches (useful for optional fields)
222/// let options = RedactOptions { allow_empty_match: true };
223///
224/// builder
225///     .redact_with_options("$.optional[*].field", "value", options)?
226///     .finish()
227///     .await;
228/// ```
229#[derive(Debug, Clone, Default)]
230#[cfg_attr(docsrs, doc(cfg(feature = "redaction")))]
231pub struct RedactOptions {
232    /// If true, matching zero paths is not an error (silent no-op).
233    ///
234    /// By default (`false`), if a path matches nothing, an error is returned.
235    /// This helps catch typos in path expressions. Set to `true` when redacting
236    /// optional fields that may not always be present.
237    pub allow_empty_match: bool,
238}
239
240/// Builder for applying redactions to JSON responses.
241///
242/// This builder allows you to chain multiple redaction operations before
243/// finalizing the result. Paths can use either JSON Pointer (RFC 6901) or
244/// JSONPath (RFC 9535) syntax.
245///
246/// # Path Syntax
247///
248/// The syntax is auto-detected based on the path prefix:
249///
250/// ## JSON Pointer (starts with `/`)
251///
252/// - `/field` - top-level field
253/// - `/field/subfield` - nested field
254/// - `/array/0` - array index
255/// - `/field~1with~1slashes` - `~1` escapes `/`
256/// - `/field~0with~0tildes` - `~0` escapes `~`
257///
258/// ## JSONPath (starts with `$`)
259///
260/// - `$.field` - top-level field
261/// - `$.items[*].id` - all `id` fields in array
262/// - `$..id` - all `id` fields anywhere (recursive)
263/// - `$[0:3]` - array slice
264#[derive(derive_more::Debug)]
265#[cfg_attr(docsrs, doc(cfg(feature = "redaction")))]
266pub struct RedactionBuilder<T> {
267    value: T,
268    redacted: serde_json::Value,
269    #[debug(skip)]
270    collector_sender: CollectorSender,
271    // Deferred response registration data
272    operation_id: String,
273    status: StatusCode,
274    content_type: Option<ContentType>,
275    schema: RefOr<Schema>,
276}
277
278impl<T> RedactionBuilder<T> {
279    /// Creates a new redaction builder with the original value and JSON.
280    pub(in crate::client) fn new(
281        value: T,
282        json: serde_json::Value,
283        collector_sender: CollectorSender,
284        operation_id: String,
285        status: StatusCode,
286        content_type: Option<ContentType>,
287        schema: RefOr<Schema>,
288    ) -> Self {
289        Self {
290            value,
291            redacted: json,
292            collector_sender,
293            operation_id,
294            status,
295            content_type,
296            schema,
297        }
298    }
299
300    /// Redacts values at the specified path using a redactor.
301    ///
302    /// The path can be either JSON Pointer (RFC 6901) or JSONPath (RFC 9535).
303    /// The syntax is auto-detected based on the prefix:
304    /// - `$...` → JSONPath (supports wildcards)
305    /// - `/...` → JSON Pointer (exact path)
306    ///
307    /// The redactor can be:
308    /// - A static value: `"replacement"` or `serde_json::json!(...)`
309    /// - A closure: `|path, val| transform(path, val)`
310    ///
311    /// # Arguments
312    ///
313    /// * `path` - Path expression (e.g., `/id`, `$.items[*].id`)
314    /// * `redactor` - The redactor to apply (static value or closure)
315    ///
316    /// # Errors
317    ///
318    /// Returns an error if:
319    /// - The path is invalid
320    /// - The path matches no values
321    ///
322    /// # Example
323    ///
324    /// ```ignore
325    /// # use clawspec_core::ApiClient;
326    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
327    /// # let client = ApiClient::builder().with_base_path("http://localhost".parse()?).build()?;
328    /// // Static value
329    /// let result = client
330    ///     .get("/api/users/123")?
331    ///     .await?
332    ///     .as_json_redacted::<serde_json::Value>().await?
333    ///     .redact("/id", "test-uuid")?
334    ///     .finish()
335    ///     .await;
336    ///
337    /// // Closure for index-based IDs
338    /// let result = client
339    ///     .get("/api/users")?
340    ///     .await?
341    ///     .as_json_redacted::<Vec<serde_json::Value>>().await?
342    ///     .redact("$[*].id", |path, _val| {
343    ///         let idx = path.split('/').nth(1).unwrap_or("0");
344    ///         serde_json::json!(format!("user-{idx}"))
345    ///     })?
346    ///     .finish()
347    ///     .await;
348    /// # Ok(())
349    /// # }
350    /// ```
351    pub fn redact<R: Redactor>(self, path: &str, redactor: R) -> Result<Self, ApiClientError> {
352        self.redact_with_options(path, redactor, RedactOptions::default())
353    }
354
355    /// Redacts values at the specified path with configurable options.
356    ///
357    /// This is like [`redact`](Self::redact) but allows customizing
358    /// behavior through [`RedactOptions`].
359    ///
360    /// # Arguments
361    ///
362    /// * `path` - Path expression (e.g., `/id`, `$.items[*].id`)
363    /// * `redactor` - The redactor to apply
364    /// * `options` - Configuration options
365    ///
366    /// # Example
367    ///
368    /// ```ignore
369    /// use clawspec_core::RedactOptions;
370    ///
371    /// // Allow empty matches for optional fields
372    /// let options = RedactOptions { allow_empty_match: true };
373    ///
374    /// builder
375    ///     .redact_with_options("$.optional[*].field", "value", options)?
376    ///     .finish()
377    ///     .await;
378    /// ```
379    pub fn redact_with_options<R: Redactor>(
380        mut self,
381        path: &str,
382        redactor: R,
383        options: RedactOptions,
384    ) -> Result<Self, ApiClientError> {
385        apply::apply_redaction(&mut self.redacted, path, redactor, options)?;
386        Ok(self)
387    }
388
389    /// Removes values at the specified path.
390    ///
391    /// This completely removes the field from objects or the element from arrays,
392    /// unlike setting it to `null`.
393    ///
394    /// The path can be either JSON Pointer (RFC 6901) or JSONPath (RFC 9535).
395    ///
396    /// # Arguments
397    ///
398    /// * `path` - Path expression to remove
399    ///
400    /// # Errors
401    ///
402    /// Returns an error if:
403    /// - The path is invalid
404    /// - The path matches no values
405    ///
406    /// # Example
407    ///
408    /// ```ignore
409    /// # use clawspec_core::ApiClient;
410    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
411    /// # let client = ApiClient::builder().with_base_path("http://localhost".parse()?).build()?;
412    /// // Remove specific field
413    /// let result = client
414    ///     .get("/api/users/123")?
415    ///     .await?
416    ///     .as_json_redacted::<serde_json::Value>().await?
417    ///     .redact_remove("/password")?
418    ///     .finish()
419    ///     .await;
420    ///
421    /// // Remove field from all array elements
422    /// let result = client
423    ///     .get("/api/users")?
424    ///     .await?
425    ///     .as_json_redacted::<Vec<serde_json::Value>>().await?
426    ///     .redact_remove("$[*].password")?
427    ///     .finish()
428    ///     .await;
429    /// # Ok(())
430    /// # }
431    /// ```
432    pub fn redact_remove(self, path: &str) -> Result<Self, ApiClientError> {
433        self.redact_remove_with(path, RedactOptions::default())
434    }
435
436    /// Removes values at the specified path with configurable options.
437    ///
438    /// This is like [`redact_remove`](Self::redact_remove) but allows customizing
439    /// behavior through [`RedactOptions`].
440    ///
441    /// # Arguments
442    ///
443    /// * `path` - Path expression to remove
444    /// * `options` - Configuration options
445    ///
446    /// # Example
447    ///
448    /// ```ignore
449    /// use clawspec_core::RedactOptions;
450    ///
451    /// // Allow empty matches for optional fields
452    /// let options = RedactOptions { allow_empty_match: true };
453    ///
454    /// builder
455    ///     .redact_remove_with("$.optional[*].field", options)?
456    ///     .finish()
457    ///     .await;
458    /// ```
459    pub fn redact_remove_with(
460        mut self,
461        path: &str,
462        options: RedactOptions,
463    ) -> Result<Self, ApiClientError> {
464        apply::apply_remove(&mut self.redacted, path, options)?;
465        Ok(self)
466    }
467
468    /// Finalizes the redaction and returns the result.
469    ///
470    /// This consumes the builder and returns a [`RedactedResult`] containing
471    /// both the original value and the redacted JSON.
472    ///
473    /// The redacted JSON value is recorded as an example in both the OpenAPI
474    /// schema for type `T` and in the response content for this operation.
475    pub async fn finish(self) -> RedactedResult<T>
476    where
477        T: ToSchema + 'static,
478    {
479        // Add example to schemas via channel
480        self.collector_sender
481            .send(CollectorMessage::AddExample {
482                type_id: TypeId::of::<T>(),
483                type_name: type_name::<T>(),
484                example: self.redacted.clone(),
485            })
486            .await;
487
488        // Register response with the redacted example via channel
489        self.collector_sender
490            .send(CollectorMessage::RegisterResponseWithExample {
491                operation_id: self.operation_id.clone(),
492                status: self.status,
493                content_type: self.content_type.clone(),
494                schema: self.schema.clone(),
495                example: self.redacted.clone(),
496            })
497            .await;
498
499        RedactedResult {
500            value: self.value,
501            redacted: self.redacted,
502        }
503    }
504}
505
506/// Creates a RedactionBuilder from a JSON string.
507///
508/// This is a helper function used internally by `CallResult::as_json_redacted()`.
509/// It deserializes the JSON into the target type and prepares it for redaction.
510///
511/// # Arguments
512///
513/// * `json` - The JSON string to deserialize and prepare for redaction
514/// * `collector_sender` - The channel sender to record the redacted example to
515/// * `operation_id` - The operation ID for deferred response registration
516/// * `status` - The HTTP status code of the response
517/// * `content_type` - The content type of the response
518/// * `schema` - The OpenAPI schema reference for the response type
519///
520/// # Type Parameters
521///
522/// * `T` - The type to deserialize into. Must implement [`DeserializeOwned`],
523///   [`ToSchema`], and have a `'static` lifetime.
524///
525/// # Errors
526///
527/// Returns an error if:
528/// - JSON deserialization fails
529/// - JSON parsing fails for the redaction copy
530pub(crate) fn create_redaction_builder<T>(
531    json: &str,
532    collector_sender: CollectorSender,
533    operation_id: String,
534    status: StatusCode,
535    content_type: Option<ContentType>,
536    schema: RefOr<Schema>,
537) -> Result<RedactionBuilder<T>, ApiClientError>
538where
539    T: DeserializeOwned + ToSchema + 'static,
540{
541    // Deserialize the original value
542    let deserializer = &mut Deserializer::from_str(json);
543    let value: T = serde_path_to_error::deserialize(deserializer).map_err(|err| {
544        ApiClientError::JsonError {
545            path: err.path().to_string(),
546            error: err.into_inner(),
547            body: json.to_string(),
548        }
549    })?;
550
551    // Parse JSON for redaction
552    let json_value = serde_json::from_str::<serde_json::Value>(json).map_err(|error| {
553        ApiClientError::JsonError {
554            path: String::new(),
555            error,
556            body: json.to_string(),
557        }
558    })?;
559
560    Ok(RedactionBuilder::new(
561        value,
562        json_value,
563        collector_sender,
564        operation_id,
565        status,
566        content_type,
567        schema,
568    ))
569}
570
571/// Create a redaction builder for an arbitrary JSON value.
572///
573/// This allows applying the same redaction patterns used for response bodies
574/// to any JSON value, such as the full OpenAPI specification.
575///
576/// # Path Syntax
577///
578/// The syntax is auto-detected based on the path prefix:
579/// - `$...` → JSONPath (RFC 9535) - supports wildcards
580/// - `/...` → JSON Pointer (RFC 6901) - exact path only
581///
582/// # Example
583///
584/// ```rust
585/// use clawspec_core::redact_value;
586/// use serde_json::json;
587///
588/// let value = json!({
589///     "id": "550e8400-e29b-41d4-a716-446655440000",
590///     "created_at": "2024-12-28T10:30:00Z"
591/// });
592///
593/// let redacted = redact_value(value)
594///     .redact("/id", "ENTITY_ID").unwrap()
595///     .redact("/created_at", "TIMESTAMP").unwrap()
596///     .finish();
597///
598/// assert_eq!(redacted["id"], "ENTITY_ID");
599/// assert_eq!(redacted["created_at"], "TIMESTAMP");
600/// ```
601///
602/// # Use Case: Stabilizing OpenAPI Specifications
603///
604/// ```rust
605/// use clawspec_core::redact_value;
606/// use serde_json::json;
607///
608/// let openapi_json = json!({
609///     "paths": {
610///         "/users": {
611///             "get": {
612///                 "responses": {
613///                     "200": {
614///                         "content": {
615///                             "application/json": {
616///                                 "example": {
617///                                     "id": "real-uuid",
618///                                     "created_at": "2024-12-28T10:30:00Z"
619///                                 }
620///                             }
621///                         }
622///                     }
623///                 }
624///             }
625///         }
626///     }
627/// });
628///
629/// // Stabilize all dynamic values in the OpenAPI spec
630/// let stabilized = redact_value(openapi_json)
631///     .redact("$..example.id", "ENTITY_ID").unwrap()
632///     .redact("$..example.created_at", "TIMESTAMP").unwrap()
633///     .finish();
634/// ```
635pub fn redact_value(value: serde_json::Value) -> ValueRedactionBuilder {
636    ValueRedactionBuilder::new(value)
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642    use http::StatusCode;
643    use serde::{Deserialize, Serialize};
644    use serde_json::json;
645    use utoipa::openapi::RefOr;
646
647    #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
648    struct TestStruct {
649        id: String,
650        name: String,
651        items: Vec<TestItem>,
652    }
653
654    #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
655    struct TestItem {
656        id: String,
657        value: i32,
658    }
659
660    fn create_test_builder() -> RedactionBuilder<TestStruct> {
661        let value = TestStruct {
662            id: "real-uuid".to_string(),
663            name: "Test".to_string(),
664            items: vec![
665                TestItem {
666                    id: "item-1".to_string(),
667                    value: 10,
668                },
669                TestItem {
670                    id: "item-2".to_string(),
671                    value: 20,
672                },
673            ],
674        };
675
676        let json = json!({
677            "id": "real-uuid",
678            "name": "Test",
679            "items": [
680                {"id": "item-1", "value": 10},
681                {"id": "item-2", "value": 20}
682            ]
683        });
684
685        let sender = CollectorSender::dummy();
686        let schema = RefOr::T(utoipa::openapi::ObjectBuilder::new().build().into());
687
688        RedactionBuilder::new(
689            value,
690            json,
691            sender,
692            "test_op".to_string(),
693            StatusCode::OK,
694            None,
695            schema,
696        )
697    }
698
699    #[test]
700    fn test_redact_options_default() {
701        let options = RedactOptions::default();
702
703        assert!(!options.allow_empty_match);
704    }
705
706    #[test]
707    fn test_redact_options_allow_empty_match() {
708        let options = RedactOptions {
709            allow_empty_match: true,
710        };
711
712        assert!(options.allow_empty_match);
713    }
714
715    #[test]
716    fn test_redact_options_debug() {
717        let options = RedactOptions {
718            allow_empty_match: true,
719        };
720        let debug_str = format!("{options:?}");
721
722        assert!(debug_str.contains("RedactOptions"));
723        assert!(debug_str.contains("allow_empty_match"));
724    }
725
726    #[test]
727    fn test_redact_options_clone() {
728        let original = RedactOptions {
729            allow_empty_match: true,
730        };
731        let cloned = original.clone();
732
733        assert_eq!(original.allow_empty_match, cloned.allow_empty_match);
734    }
735
736    #[test]
737    fn test_redaction_builder_redact_success() {
738        let builder = create_test_builder();
739        let result = builder.redact("/id", "stable-uuid");
740
741        assert!(result.is_ok());
742        let builder = result.expect("redaction should succeed");
743        assert_eq!(
744            builder.redacted.get("id").and_then(|v| v.as_str()),
745            Some("stable-uuid")
746        );
747    }
748
749    #[test]
750    fn test_redaction_builder_redact_jsonpath_wildcard() {
751        let builder = create_test_builder();
752        let result = builder.redact("$.items[*].id", "redacted-id");
753
754        assert!(result.is_ok());
755        let builder = result.expect("redaction should succeed");
756        let items = builder.redacted.get("items").expect("should have items");
757        let items_array = items.as_array().expect("should be array");
758
759        for item in items_array {
760            assert_eq!(item.get("id").and_then(|v| v.as_str()), Some("redacted-id"));
761        }
762    }
763
764    #[test]
765    fn test_redaction_builder_redact_with_closure() {
766        let builder = create_test_builder();
767        let result = builder.redact("$.items[*].id", |path: &str, _val: &serde_json::Value| {
768            let idx = path.split('/').nth(2).unwrap_or("?");
769            json!(format!("item-idx-{idx}"))
770        });
771
772        assert!(result.is_ok());
773        let builder = result.expect("redaction should succeed");
774        let items = builder.redacted.get("items").expect("should have items");
775        let items_array = items.as_array().expect("should be array");
776
777        assert_eq!(
778            items_array[0].get("id").and_then(|v| v.as_str()),
779            Some("item-idx-0")
780        );
781        assert_eq!(
782            items_array[1].get("id").and_then(|v| v.as_str()),
783            Some("item-idx-1")
784        );
785    }
786
787    #[test]
788    fn test_redaction_builder_redact_error_no_match() {
789        let builder = create_test_builder();
790        let result = builder.redact("$.nonexistent", "value");
791
792        assert!(result.is_err());
793    }
794
795    #[test]
796    fn test_redaction_builder_redact_with_options_allow_empty() {
797        let builder = create_test_builder();
798        let options = RedactOptions {
799            allow_empty_match: true,
800        };
801        let result = builder.redact_with_options("$.nonexistent", "value", options);
802
803        assert!(result.is_ok());
804    }
805
806    #[test]
807    fn test_redaction_builder_redact_remove_success() {
808        let builder = create_test_builder();
809        let result = builder.redact_remove("/id");
810
811        assert!(result.is_ok());
812        let builder = result.expect("removal should succeed");
813        assert!(builder.redacted.get("id").is_none());
814    }
815
816    #[test]
817    fn test_redaction_builder_redact_remove_jsonpath() {
818        let builder = create_test_builder();
819        let result = builder.redact_remove("$.items[*].id");
820
821        assert!(result.is_ok());
822        let builder = result.expect("removal should succeed");
823        let items = builder.redacted.get("items").expect("should have items");
824        let items_array = items.as_array().expect("should be array");
825
826        for item in items_array {
827            assert!(item.get("id").is_none());
828        }
829    }
830
831    #[test]
832    fn test_redaction_builder_redact_remove_error_no_match() {
833        let builder = create_test_builder();
834        let result = builder.redact_remove("$.nonexistent");
835
836        assert!(result.is_err());
837    }
838
839    #[test]
840    fn test_redaction_builder_redact_remove_with_allow_empty() {
841        let builder = create_test_builder();
842        let options = RedactOptions {
843            allow_empty_match: true,
844        };
845        let result = builder.redact_remove_with("$.nonexistent", options);
846
847        assert!(result.is_ok());
848    }
849
850    #[test]
851    fn test_redaction_builder_chained_redactions() {
852        let builder = create_test_builder();
853        let result = builder
854            .redact("/id", "stable-id")
855            .and_then(|b| b.redact("/name", "Redacted Name"))
856            .and_then(|b| b.redact("$.items[*].id", "item-redacted"));
857
858        assert!(result.is_ok());
859        let builder = result.expect("chained redactions should succeed");
860        assert_eq!(
861            builder.redacted.get("id").and_then(|v| v.as_str()),
862            Some("stable-id")
863        );
864        assert_eq!(
865            builder.redacted.get("name").and_then(|v| v.as_str()),
866            Some("Redacted Name")
867        );
868    }
869
870    #[test]
871    fn test_redaction_builder_preserves_original_value() {
872        let builder = create_test_builder();
873        let original_id = builder.value.id.clone();
874        let result = builder.redact("/id", "stable-id");
875
876        assert!(result.is_ok());
877        let builder = result.expect("redaction should succeed");
878        // Original value should be preserved
879        assert_eq!(builder.value.id, original_id);
880        // Redacted value should be different
881        assert_eq!(
882            builder.redacted.get("id").and_then(|v| v.as_str()),
883            Some("stable-id")
884        );
885    }
886
887    #[test]
888    fn test_redacted_result_fields() {
889        let result = RedactedResult {
890            value: TestStruct {
891                id: "real".to_string(),
892                name: "Test".to_string(),
893                items: vec![],
894            },
895            redacted: json!({"id": "fake", "name": "Test", "items": []}),
896        };
897
898        assert_eq!(result.value.id, "real");
899        assert_eq!(
900            result.redacted.get("id").and_then(|v| v.as_str()),
901            Some("fake")
902        );
903    }
904
905    #[test]
906    fn test_redacted_result_debug() {
907        let result = RedactedResult {
908            value: "test".to_string(),
909            redacted: json!("redacted"),
910        };
911        let debug_str = format!("{result:?}");
912
913        assert!(debug_str.contains("RedactedResult"));
914    }
915
916    #[test]
917    fn test_redacted_result_clone() {
918        let original = RedactedResult {
919            value: "test".to_string(),
920            redacted: json!("redacted"),
921        };
922        let cloned = original.clone();
923
924        assert_eq!(original.value, cloned.value);
925        assert_eq!(original.redacted, cloned.redacted);
926    }
927
928    #[test]
929    fn test_redaction_builder_debug() {
930        let builder = create_test_builder();
931        let debug_str = format!("{builder:?}");
932
933        assert!(debug_str.contains("RedactionBuilder"));
934        assert!(debug_str.contains("operation_id"));
935    }
936
937    #[test]
938    fn test_create_redaction_builder_success() {
939        let json = r#"{"id": "uuid", "name": "Test"}"#;
940        let sender = CollectorSender::dummy();
941        let schema = RefOr::T(utoipa::openapi::ObjectBuilder::new().build().into());
942
943        let result = create_redaction_builder::<serde_json::Value>(
944            json,
945            sender,
946            "op_id".to_string(),
947            StatusCode::OK,
948            None,
949            schema,
950        );
951
952        assert!(result.is_ok());
953    }
954
955    #[test]
956    fn test_create_redaction_builder_invalid_json() {
957        let json = r#"{"invalid json"#;
958        let sender = CollectorSender::dummy();
959        let schema = RefOr::T(utoipa::openapi::ObjectBuilder::new().build().into());
960
961        let result = create_redaction_builder::<serde_json::Value>(
962            json,
963            sender,
964            "op_id".to_string(),
965            StatusCode::OK,
966            None,
967            schema,
968        );
969
970        assert!(result.is_err());
971    }
972
973    #[test]
974    fn test_invalid_path_syntax_error() {
975        let builder = create_test_builder();
976        // Path without $ or / prefix should fail
977        let result = builder.redact("invalid_path", "value");
978
979        assert!(result.is_err());
980    }
981
982    #[test]
983    fn test_invalid_jsonpath_syntax() {
984        let builder = create_test_builder();
985        // Invalid JSONPath syntax
986        let result = builder.redact("$[[[invalid", "value");
987
988        assert!(result.is_err());
989    }
990
991    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
992    struct LoginResponse {
993        session_token: String,
994        user_id: String,
995    }
996
997    fn get_response_example(
998        openapi: &utoipa::openapi::OpenApi,
999        path: &str,
1000        method: &str,
1001        status_code: &str,
1002    ) -> Option<serde_json::Value> {
1003        let path_item = openapi.paths.paths.get(path)?;
1004        let operation = match method.to_uppercase().as_str() {
1005            "POST" => path_item.post.as_ref(),
1006            "PUT" => path_item.put.as_ref(),
1007            "PATCH" => path_item.patch.as_ref(),
1008            "DELETE" => path_item.delete.as_ref(),
1009            "GET" => path_item.get.as_ref(),
1010            _ => None,
1011        }?;
1012        let response = operation.responses.responses.get(status_code)?;
1013        let utoipa::openapi::RefOr::T(response) = response else {
1014            return None;
1015        };
1016        let content = response.content.get("application/json")?;
1017        content.example.clone()
1018    }
1019
1020    #[tokio::test]
1021    async fn should_return_original_value_and_use_redacted_in_openapi_for_response() {
1022        use wiremock::matchers::{method, path};
1023        use wiremock::{Mock, MockServer, ResponseTemplate};
1024
1025        use crate::client::ApiClient;
1026
1027        // 1. Start mock server
1028        let mock_server = MockServer::start().await;
1029
1030        // 2. Set up mock to return a response with sensitive data
1031        Mock::given(method("POST"))
1032            .and(path("/api/login"))
1033            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1034                "session_token": "secret-token-abc123",
1035                "user_id": "user-42"
1036            })))
1037            .expect(1)
1038            .mount(&mock_server)
1039            .await;
1040
1041        // 3. Create ApiClient pointing to mock server
1042        let uri: http::Uri = mock_server.uri().parse().expect("valid URI");
1043        let mut client = ApiClient::builder()
1044            .with_host(uri.host().expect("should have host"))
1045            .with_port(uri.port_u16().expect("should have port"))
1046            .build()
1047            .expect("should build client");
1048
1049        // 4. Make request and apply response redaction
1050        let result = client
1051            .post("/api/login")
1052            .expect("should create call")
1053            .await
1054            .expect("request should succeed")
1055            .as_json_redacted::<LoginResponse>()
1056            .await
1057            .expect("should parse response")
1058            .redact("/session_token", "[REDACTED]")
1059            .expect("should redact")
1060            .finish()
1061            .await;
1062
1063        // 5. Verify the returned value contains the ORIGINAL (unredacted) data
1064        assert_eq!(
1065            result.value.session_token, "secret-token-abc123",
1066            "Original value should have real session token"
1067        );
1068        assert_eq!(
1069            result.value.user_id, "user-42",
1070            "Original value should have real user_id"
1071        );
1072
1073        // 6. Verify the redacted field in the result
1074        assert_eq!(
1075            result
1076                .redacted
1077                .get("session_token")
1078                .and_then(|v| v.as_str()),
1079            Some("[REDACTED]"),
1080            "Redacted result should have redacted session_token"
1081        );
1082
1083        // 7. Verify OpenAPI example contains the redacted value
1084        let openapi = client.collected_openapi().await;
1085        let example = get_response_example(&openapi, "/api/login", "POST", "200")
1086            .expect("should have response example");
1087
1088        assert_eq!(
1089            example.get("session_token").and_then(|v| v.as_str()),
1090            Some("[REDACTED]"),
1091            "OpenAPI example should have redacted session_token"
1092        );
1093        assert_eq!(
1094            example.get("user_id").and_then(|v| v.as_str()),
1095            Some("user-42"),
1096            "OpenAPI example should have original user_id"
1097        );
1098    }
1099}