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}