Skip to main content

clawspec_core/client/response/redaction/
value_builder.rs

1//! Builder for redacting arbitrary JSON values.
2//!
3//! This module provides [`ValueRedactionBuilder`] for applying redactions to any
4//! `serde_json::Value`, independent of HTTP response handling. This is useful for:
5//!
6//! - Stabilizing dynamic values in generated OpenAPI specifications
7//! - Post-processing JSON before writing to files
8//! - Applying consistent redaction patterns across different contexts
9//!
10//! # Example
11//!
12//! ```rust
13//! use clawspec_core::redact_value;
14//! use serde_json::json;
15//!
16//! let value = json!({
17//!     "id": "550e8400-e29b-41d4-a716-446655440000",
18//!     "created_at": "2024-12-28T10:30:00Z",
19//!     "items": [
20//!         {"entity_id": "uuid-1"},
21//!         {"entity_id": "uuid-2"}
22//!     ]
23//! });
24//!
25//! let redacted = redact_value(value)
26//!     .redact("/id", "ENTITY_ID").unwrap()
27//!     .redact("/created_at", "TIMESTAMP").unwrap()
28//!     .redact("$.items[*].entity_id", "NESTED_ID").unwrap()
29//!     .finish();
30//!
31//! assert_eq!(redacted["id"], "ENTITY_ID");
32//! assert_eq!(redacted["created_at"], "TIMESTAMP");
33//! assert_eq!(redacted["items"][0]["entity_id"], "NESTED_ID");
34//! ```
35
36use serde_json::Value;
37
38use super::RedactOptions;
39use super::apply::{apply_redaction, apply_remove};
40use super::redactor::Redactor;
41use crate::client::error::ApiClientError;
42
43/// Builder for redacting arbitrary JSON values.
44///
45/// Unlike [`RedactionBuilder`](super::RedactionBuilder), this builder works with any
46/// `serde_json::Value` without requiring HTTP response context or OpenAPI collection.
47///
48/// # Path Syntax
49///
50/// The syntax is auto-detected based on the path prefix:
51///
52/// ## JSON Pointer (starts with `/`)
53///
54/// - `/field` - top-level field
55/// - `/field/subfield` - nested field
56/// - `/array/0` - array index
57/// - `/field~1with~1slashes` - `~1` escapes `/`
58/// - `/field~0with~0tildes` - `~0` escapes `~`
59///
60/// ## JSONPath (starts with `$`)
61///
62/// - `$.field` - top-level field
63/// - `$.items[*].id` - all `id` fields in array
64/// - `$..id` - all `id` fields anywhere (recursive)
65/// - `$[0:3]` - array slice
66///
67/// # Example
68///
69/// ```rust
70/// use clawspec_core::redact_value;
71/// use serde_json::json;
72///
73/// let openapi_json = json!({
74///     "paths": {
75///         "/users": {
76///             "get": {
77///                 "responses": {
78///                     "200": {
79///                         "content": {
80///                             "application/json": {
81///                                 "example": {
82///                                     "id": "real-uuid",
83///                                     "created_at": "2024-12-28T10:30:00Z"
84///                                 }
85///                             }
86///                         }
87///                     }
88///                 }
89///             }
90///         }
91///     }
92/// });
93///
94/// let stabilized = redact_value(openapi_json)
95///     .redact("$..example.id", "ENTITY_ID").unwrap()
96///     .redact("$..example.created_at", "TIMESTAMP").unwrap()
97///     .finish();
98/// ```
99#[derive(Debug, Clone)]
100#[cfg_attr(docsrs, doc(cfg(feature = "redaction")))]
101pub struct ValueRedactionBuilder {
102    value: Value,
103}
104
105impl ValueRedactionBuilder {
106    /// Create a new builder for the given JSON value.
107    pub fn new(value: Value) -> Self {
108        Self { value }
109    }
110
111    /// Redacts values at the specified path using a redactor.
112    ///
113    /// The path can be either JSON Pointer (RFC 6901) or JSONPath (RFC 9535).
114    /// The syntax is auto-detected based on the prefix:
115    /// - `$...` → JSONPath (supports wildcards)
116    /// - `/...` → JSON Pointer (exact path)
117    ///
118    /// The redactor can be:
119    /// - A static value: `"replacement"` or `serde_json::json!(...)`
120    /// - A closure: `|path, val| transform(path, val)`
121    ///
122    /// # Arguments
123    ///
124    /// * `path` - Path expression (e.g., `/id`, `$.items[*].id`)
125    /// * `redactor` - The redactor to apply (static value or closure)
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if:
130    /// - The path is invalid
131    /// - The path matches no values
132    ///
133    /// # Example
134    ///
135    /// ```rust
136    /// use clawspec_core::redact_value;
137    /// use serde_json::json;
138    ///
139    /// let value = json!({"id": "uuid-123", "name": "Test"});
140    ///
141    /// // Static value
142    /// let redacted = redact_value(value)
143    ///     .redact("/id", "stable-uuid").unwrap()
144    ///     .finish();
145    ///
146    /// assert_eq!(redacted["id"], "stable-uuid");
147    /// ```
148    pub fn redact<R: Redactor>(self, path: &str, redactor: R) -> Result<Self, ApiClientError> {
149        self.redact_with_options(path, redactor, RedactOptions::default())
150    }
151
152    /// Redacts values at the specified path with configurable options.
153    ///
154    /// This is like [`redact`](Self::redact) but allows customizing
155    /// behavior through [`RedactOptions`].
156    ///
157    /// # Arguments
158    ///
159    /// * `path` - Path expression (e.g., `/id`, `$.items[*].id`)
160    /// * `redactor` - The redactor to apply
161    /// * `options` - Configuration options
162    ///
163    /// # Example
164    ///
165    /// ```rust
166    /// use clawspec_core::{redact_value, RedactOptions};
167    /// use serde_json::json;
168    ///
169    /// let value = json!({"id": "test"});
170    ///
171    /// // Allow empty matches for optional fields
172    /// let options = RedactOptions { allow_empty_match: true };
173    ///
174    /// let redacted = redact_value(value)
175    ///     .redact_with_options("$.optional_field", "value", options).unwrap()
176    ///     .finish();
177    /// ```
178    pub fn redact_with_options<R: Redactor>(
179        mut self,
180        path: &str,
181        redactor: R,
182        options: RedactOptions,
183    ) -> Result<Self, ApiClientError> {
184        apply_redaction(&mut self.value, path, redactor, options)?;
185        Ok(self)
186    }
187
188    /// Removes values at the specified path.
189    ///
190    /// This completely removes the field from objects or the element from arrays,
191    /// unlike setting it to `null`.
192    ///
193    /// The path can be either JSON Pointer (RFC 6901) or JSONPath (RFC 9535).
194    ///
195    /// # Arguments
196    ///
197    /// * `path` - Path expression to remove
198    ///
199    /// # Errors
200    ///
201    /// Returns an error if:
202    /// - The path is invalid
203    /// - The path matches no values
204    ///
205    /// # Example
206    ///
207    /// ```rust
208    /// use clawspec_core::redact_value;
209    /// use serde_json::json;
210    ///
211    /// let value = json!({"id": "keep", "secret": "remove"});
212    ///
213    /// let redacted = redact_value(value)
214    ///     .redact_remove("/secret").unwrap()
215    ///     .finish();
216    ///
217    /// assert!(redacted.get("secret").is_none());
218    /// assert_eq!(redacted["id"], "keep");
219    /// ```
220    pub fn redact_remove(self, path: &str) -> Result<Self, ApiClientError> {
221        self.redact_remove_with(path, RedactOptions::default())
222    }
223
224    /// Removes values at the specified path with configurable options.
225    ///
226    /// This is like [`redact_remove`](Self::redact_remove) but allows customizing
227    /// behavior through [`RedactOptions`].
228    ///
229    /// # Arguments
230    ///
231    /// * `path` - Path expression to remove
232    /// * `options` - Configuration options
233    ///
234    /// # Example
235    ///
236    /// ```rust
237    /// use clawspec_core::{redact_value, RedactOptions};
238    /// use serde_json::json;
239    ///
240    /// let value = json!({"id": "test"});
241    ///
242    /// // Allow empty matches for optional fields
243    /// let options = RedactOptions { allow_empty_match: true };
244    ///
245    /// let redacted = redact_value(value)
246    ///     .redact_remove_with("$.optional_field", options).unwrap()
247    ///     .finish();
248    /// ```
249    pub fn redact_remove_with(
250        mut self,
251        path: &str,
252        options: RedactOptions,
253    ) -> Result<Self, ApiClientError> {
254        apply_remove(&mut self.value, path, options)?;
255        Ok(self)
256    }
257
258    /// Finalize and return the redacted value.
259    ///
260    /// This consumes the builder and returns the modified JSON value.
261    pub fn finish(self) -> Value {
262        self.value
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use serde_json::json;
270
271    #[test]
272    fn should_redact_with_json_pointer() {
273        let value = json!({
274            "id": "550e8400-e29b-41d4-a716-446655440000",
275            "name": "Test"
276        });
277
278        let redacted = ValueRedactionBuilder::new(value)
279            .redact("/id", "REDACTED_ID")
280            .expect("redaction should succeed")
281            .finish();
282
283        assert_eq!(redacted["id"], "REDACTED_ID");
284        assert_eq!(redacted["name"], "Test");
285    }
286
287    #[test]
288    fn should_redact_with_jsonpath_wildcards() {
289        let value = json!({
290            "items": [
291                {"id": "uuid-1", "name": "Item 1"},
292                {"id": "uuid-2", "name": "Item 2"}
293            ]
294        });
295
296        let redacted = ValueRedactionBuilder::new(value)
297            .redact("$.items[*].id", "REDACTED")
298            .expect("redaction should succeed")
299            .finish();
300
301        let items = redacted["items"].as_array().expect("should be array");
302        assert_eq!(items[0]["id"], "REDACTED");
303        assert_eq!(items[0]["name"], "Item 1");
304        assert_eq!(items[1]["id"], "REDACTED");
305        assert_eq!(items[1]["name"], "Item 2");
306    }
307
308    #[test]
309    fn should_redact_with_recursive_descent() {
310        let value = json!({
311            "id": "root-uuid",
312            "nested": {
313                "id": "nested-uuid",
314                "deep": {
315                    "id": "deep-uuid"
316                }
317            }
318        });
319
320        let redacted = ValueRedactionBuilder::new(value)
321            .redact("$..id", "REDACTED")
322            .expect("redaction should succeed")
323            .finish();
324
325        assert_eq!(redacted["id"], "REDACTED");
326        assert_eq!(redacted["nested"]["id"], "REDACTED");
327        assert_eq!(redacted["nested"]["deep"]["id"], "REDACTED");
328    }
329
330    #[test]
331    fn should_redact_with_closure() {
332        let value = json!({
333            "price": 19.99,
334            "tax": 1.234567
335        });
336
337        let redacted = ValueRedactionBuilder::new(value)
338            .redact("$.*", |_path: &str, val: &Value| {
339                if let Some(n) = val.as_f64() {
340                    json!((n * 100.0).round() / 100.0)
341                } else {
342                    val.clone()
343                }
344            })
345            .expect("redaction should succeed")
346            .finish();
347
348        assert_eq!(redacted["price"], 19.99);
349        assert_eq!(redacted["tax"], 1.23);
350    }
351
352    #[test]
353    fn should_redact_with_index_based_closure() {
354        let value = json!({
355            "items": [
356                {"id": "uuid-a"},
357                {"id": "uuid-b"},
358                {"id": "uuid-c"}
359            ]
360        });
361
362        let redacted = ValueRedactionBuilder::new(value)
363            .redact("$.items[*].id", |path: &str, _val: &Value| {
364                let idx = path.split('/').nth(2).unwrap_or("?");
365                json!(format!("item-{idx}"))
366            })
367            .expect("redaction should succeed")
368            .finish();
369
370        let items = redacted["items"].as_array().expect("should be array");
371        assert_eq!(items[0]["id"], "item-0");
372        assert_eq!(items[1]["id"], "item-1");
373        assert_eq!(items[2]["id"], "item-2");
374    }
375
376    #[test]
377    fn should_chain_multiple_redactions() {
378        let value = json!({
379            "entity_id": "uuid-123",
380            "created_at": "2024-12-28T10:30:00Z",
381            "nested": {
382                "entity_id": "uuid-456"
383            }
384        });
385
386        let redacted = ValueRedactionBuilder::new(value)
387            .redact("$..entity_id", "ENTITY_ID")
388            .expect("redaction should succeed")
389            .redact("$..created_at", "TIMESTAMP")
390            .expect("redaction should succeed")
391            .finish();
392
393        assert_eq!(redacted["entity_id"], "ENTITY_ID");
394        assert_eq!(redacted["created_at"], "TIMESTAMP");
395        assert_eq!(redacted["nested"]["entity_id"], "ENTITY_ID");
396    }
397
398    #[test]
399    fn should_handle_remove() {
400        let value = json!({
401            "id": "keep-this",
402            "secret": "remove-this"
403        });
404
405        let redacted = ValueRedactionBuilder::new(value)
406            .redact_remove("/secret")
407            .expect("removal should succeed")
408            .finish();
409
410        assert_eq!(redacted["id"], "keep-this");
411        assert!(redacted.get("secret").is_none());
412    }
413
414    #[test]
415    fn should_handle_remove_with_jsonpath() {
416        let value = json!({
417            "items": [
418                {"id": "a", "secret": "x"},
419                {"id": "b", "secret": "y"}
420            ]
421        });
422
423        let redacted = ValueRedactionBuilder::new(value)
424            .redact_remove("$.items[*].secret")
425            .expect("removal should succeed")
426            .finish();
427
428        let items = redacted["items"].as_array().expect("should be array");
429        assert_eq!(items[0]["id"], "a");
430        assert!(items[0].get("secret").is_none());
431        assert_eq!(items[1]["id"], "b");
432        assert!(items[1].get("secret").is_none());
433    }
434
435    #[test]
436    fn should_fail_on_no_match_by_default() {
437        let value = json!({"id": "test"});
438
439        let err = ValueRedactionBuilder::new(value)
440            .redact("/nonexistent", "REDACTED")
441            .expect_err("should fail for missing path");
442
443        assert!(matches!(err, ApiClientError::RedactionError { .. }));
444    }
445
446    #[test]
447    fn should_respect_allow_empty_match_option() {
448        let value = json!({"id": "test"});
449
450        // allow_empty_match is for JSONPath patterns that might match nothing
451        let options = RedactOptions {
452            allow_empty_match: true,
453        };
454        let redacted = ValueRedactionBuilder::new(value)
455            .redact_with_options("$.nonexistent", "REDACTED", options)
456            .expect("should succeed with allow_empty_match")
457            .finish();
458
459        assert_eq!(redacted["id"], "test");
460    }
461
462    #[test]
463    fn should_handle_json_value_redactor() {
464        let value = json!({"data": "old"});
465
466        let redacted = ValueRedactionBuilder::new(value)
467            .redact("/data", json!({"nested": "value"}))
468            .expect("redaction should succeed")
469            .finish();
470
471        assert_eq!(redacted["data"]["nested"], "value");
472    }
473
474    #[test]
475    fn should_handle_openapi_example_redaction() {
476        let openapi = json!({
477            "paths": {
478                "/users": {
479                    "get": {
480                        "responses": {
481                            "200": {
482                                "content": {
483                                    "application/json": {
484                                        "example": {
485                                            "id": "real-uuid-here",
486                                            "created_at": "2024-12-28T15:30:00Z"
487                                        }
488                                    }
489                                }
490                            }
491                        }
492                    }
493                }
494            }
495        });
496
497        let stabilized = ValueRedactionBuilder::new(openapi)
498            .redact("$..example.id", "ENTITY_ID")
499            .expect("redaction should succeed")
500            .redact("$..example.created_at", "TIMESTAMP")
501            .expect("redaction should succeed")
502            .finish();
503
504        let example = &stabilized["paths"]["/users"]["get"]["responses"]["200"]["content"]["application/json"]
505            ["example"];
506        assert_eq!(example["id"], "ENTITY_ID");
507        assert_eq!(example["created_at"], "TIMESTAMP");
508    }
509}