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}