clawspec_core/_tutorial/chapter_6.rs
1//! # Chapter 6: Redaction
2//!
3//! This chapter covers the redaction feature for creating stable OpenAPI examples.
4//!
5//! > **Note:** This feature requires the `redaction` feature flag:
6//! > ```toml
7//! > clawspec-core = { version = "0.2", features = ["redaction"] }
8//! > ```
9//!
10//! ## The Problem with Dynamic Values
11//!
12//! When generating OpenAPI examples from real API responses, dynamic values like
13//! UUIDs, timestamps, and tokens change with every test run:
14//!
15//! ```json
16//! {
17//! "id": "550e8400-e29b-41d4-a716-446655440000",
18//! "created_at": "2024-03-15T10:30:45.123Z",
19//! "session_token": "eyJhbGciOiJIUzI1NiIs..."
20//! }
21//! ```
22//!
23//! This causes problems:
24//! - **Snapshot tests fail** because examples change each run
25//! - **Documentation is inconsistent** across builds
26//! - **Sensitive values** might leak into docs
27//!
28//! ## Solution: Redaction
29//!
30//! Redaction lets you replace dynamic values with stable placeholders in OpenAPI
31//! examples while preserving the real values for your test assertions.
32//!
33#![cfg_attr(feature = "redaction", doc = "```rust,no_run")]
34#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
35//! use clawspec_core::ApiClient;
36//! use serde::Deserialize;
37//! use utoipa::ToSchema;
38//!
39//! #[derive(Deserialize, ToSchema)]
40//! struct User {
41//! id: String,
42//! name: String,
43//! created_at: String,
44//! }
45//!
46//! # #[tokio::main]
47//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
48//! # let mut client = ApiClient::builder().build()?;
49//! // Use as_json_redacted instead of as_json
50//! let result = client
51//! .post("/users")?
52//! .json(&serde_json::json!({"name": "Alice"}))?
53//! .await?
54//! .as_json_redacted::<User>()
55//! .await?
56//! // Replace dynamic values with stable placeholders
57//! .redact("/id", "00000000-0000-0000-0000-000000000001")?
58//! .redact("/created_at", "2024-01-01T00:00:00Z")?
59//! .finish()
60//! .await;
61//!
62//! // result.value has the REAL dynamic values for assertions
63//! let user = result.value;
64//! assert!(!user.id.is_empty());
65//! assert!(!user.created_at.is_empty());
66//!
67//! // result.redacted has the STABLE values for OpenAPI
68//! let redacted = result.redacted;
69//! assert_eq!(redacted["id"], "00000000-0000-0000-0000-000000000001");
70//! # Ok(())
71//! # }
72//! ```
73//!
74//! ## Redaction Operations
75//!
76//! ### Replace Values
77//!
78//! Use `redact` to substitute a value:
79//!
80#![cfg_attr(feature = "redaction", doc = "```rust,no_run")]
81#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
82//! # use clawspec_core::ApiClient;
83//! # use serde::Deserialize;
84//! # use utoipa::ToSchema;
85//! # #[derive(Deserialize, ToSchema)]
86//! # struct Response { token: String, timestamp: String }
87//! # #[tokio::main]
88//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
89//! # let mut client = ApiClient::builder().build()?;
90//! let result = client.post("/auth")?
91//! .json(&serde_json::json!({"user": "alice"}))?
92//! .await?
93//! .as_json_redacted::<Response>()
94//! .await?
95//! .redact("/token", "[REDACTED]")?
96//! .redact("/timestamp", "2024-01-01T00:00:00Z")?
97//! .finish()
98//! .await;
99//! # Ok(())
100//! # }
101//! ```
102//!
103//! ### Remove Values
104//!
105//! Use `redact_remove` to exclude a field entirely:
106//!
107#![cfg_attr(feature = "redaction", doc = "```rust,no_run")]
108#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
109//! # use clawspec_core::ApiClient;
110//! # use serde::Deserialize;
111//! # use utoipa::ToSchema;
112//! # #[derive(Deserialize, ToSchema)]
113//! # struct Response { public_id: String, internal_ref: String }
114//! # #[tokio::main]
115//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
116//! # let mut client = ApiClient::builder().build()?;
117//! let result = client.get("/data")?
118//! .await?
119//! .as_json_redacted::<Response>()
120//! .await?
121//! .redact("/public_id", "id-001")?
122//! .redact_remove("/internal_ref")? // Completely remove from example
123//! .finish()
124//! .await;
125//! # Ok(())
126//! # }
127//! ```
128//!
129//! ## Path Syntax
130//!
131//! Paths are auto-detected based on their prefix:
132//! - Paths starting with `/` use JSON Pointer (RFC 6901) - exact paths only
133//! - Paths starting with `$` use JSONPath (RFC 9535) - supports wildcards
134//!
135//! ## JSON Pointer Syntax
136//!
137//! [JSON Pointer (RFC 6901)](https://tools.ietf.org/html/rfc6901) uses `/`
138//! as a path separator for exact paths:
139//!
140//! | Pointer | Description |
141//! |---------|-------------|
142//! | `/id` | Top-level field "id" |
143//! | `/user/name` | Nested field "name" inside "user" |
144//! | `/items/0` | First element of "items" array |
145//! | `/items/0/id` | "id" of first element in "items" |
146//! | `/foo~1bar` | Field named "foo/bar" (`/` escaped as `~1`) |
147//! | `/foo~0bar` | Field named "foo~bar" (`~` escaped as `~0`) |
148//!
149//! ### Nested Object Example
150//!
151#![cfg_attr(feature = "redaction", doc = "```rust,no_run")]
152#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
153//! # use clawspec_core::ApiClient;
154//! # use serde::Deserialize;
155//! # use utoipa::ToSchema;
156//! #[derive(Deserialize, ToSchema)]
157//! struct Order {
158//! id: String,
159//! customer: Customer,
160//! items: Vec<Item>,
161//! }
162//!
163//! #[derive(Deserialize, ToSchema)]
164//! struct Customer {
165//! id: String,
166//! email: String,
167//! }
168//!
169//! #[derive(Deserialize, ToSchema)]
170//! struct Item {
171//! sku: String,
172//! quantity: u32,
173//! }
174//!
175//! # #[tokio::main]
176//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
177//! # let mut client = ApiClient::builder().build()?;
178//! let result = client.get("/orders/123")?
179//! .await?
180//! .as_json_redacted::<Order>()
181//! .await?
182//! .redact("/id", "order-001")?
183//! .redact("/customer/id", "customer-001")?
184//! .redact("/customer/email", "user@example.com")?
185//! .redact("/items/0/sku", "SKU-001")?
186//! .finish()
187//! .await;
188//! # Ok(())
189//! # }
190//! ```
191//!
192//! ## JSONPath Wildcards
193//!
194//! For arrays or deeply nested structures, use [JSONPath (RFC 9535)](https://www.rfc-editor.org/rfc/rfc9535)
195//! syntax which starts with `$`:
196//!
197//! | JSONPath | Description |
198//! |----------|-------------|
199//! | `$[*].id` | All `id` fields in root array |
200//! | `$.items[*].id` | All `id` fields in `items` array |
201//! | `$..id` | All `id` fields anywhere (recursive descent) |
202//! | `$[0:3]` | First 3 elements of root array |
203//!
204//! ### Array Redaction Example
205//!
206#![cfg_attr(feature = "redaction", doc = "```rust,no_run")]
207#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
208//! # use clawspec_core::ApiClient;
209//! # use serde::Deserialize;
210//! # use utoipa::ToSchema;
211//! #[derive(Deserialize, ToSchema)]
212//! struct UserList {
213//! users: Vec<User>,
214//! }
215//!
216//! #[derive(Deserialize, ToSchema)]
217//! struct User {
218//! id: String,
219//! name: String,
220//! created_at: String,
221//! }
222//!
223//! # #[tokio::main]
224//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
225//! # let mut client = ApiClient::builder().build()?;
226//! let result = client.get("/users")?
227//! .await?
228//! .as_json_redacted::<UserList>()
229//! .await?
230//! // Redact ALL user IDs with a single call
231//! .redact("$.users[*].id", "stable-user-id")?
232//! // Redact ALL timestamps
233//! .redact("$.users[*].created_at", "2024-01-01T00:00:00Z")?
234//! .finish()
235//! .await;
236//! # Ok(())
237//! # }
238//! ```
239//!
240//! ## Function-Based Redaction
241//!
242//! For dynamic transformations, pass a closure instead of a static value.
243//! The closure receives the concrete JSON Pointer path and current value:
244//!
245//! ### Index-Aware IDs
246//!
247//! Create stable, distinguishable IDs based on array position:
248//!
249#![cfg_attr(feature = "redaction", doc = "```rust,no_run")]
250#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
251//! # use clawspec_core::ApiClient;
252//! # use serde::Deserialize;
253//! # use serde_json::Value;
254//! # use utoipa::ToSchema;
255//! # #[derive(Deserialize, ToSchema)]
256//! # struct UserList { users: Vec<User> }
257//! # #[derive(Deserialize, ToSchema)]
258//! # struct User { id: String, name: String }
259//! # #[tokio::main]
260//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
261//! # let mut client = ApiClient::builder().build()?;
262//! let result = client.get("/users")?
263//! .await?
264//! .as_json_redacted::<UserList>()
265//! .await?
266//! // Closure receives path like "/users/0/id", "/users/1/id", etc.
267//! .redact("$.users[*].id", |path: &str, _val: &Value| {
268//! // Extract index from path: "/users/0/id" -> "0"
269//! let idx = path.split('/').nth(2).unwrap_or("0");
270//! serde_json::json!(format!("user-{idx}"))
271//! })?
272//! .finish()
273//! .await;
274//!
275//! // Result: user-0, user-1, user-2, etc.
276//! # Ok(())
277//! # }
278//! ```
279//!
280//! ### Value-Based Transformation
281//!
282//! Transform based on the current value (path can be ignored):
283//!
284#![cfg_attr(feature = "redaction", doc = "```rust,no_run")]
285#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
286//! # use clawspec_core::ApiClient;
287//! # use serde::Deserialize;
288//! # use serde_json::Value;
289//! # use utoipa::ToSchema;
290//! # #[derive(Deserialize, ToSchema)]
291//! # struct Document { notes: String }
292//! # #[tokio::main]
293//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
294//! # let mut client = ApiClient::builder().build()?;
295//! let result = client.get("/documents")?
296//! .await?
297//! .as_json_redacted::<Vec<Document>>()
298//! .await?
299//! // Redact long notes, keep short ones
300//! .redact("$[*].notes", |_path: &str, val: &Value| {
301//! if val.as_str().map(|s| s.len() > 50).unwrap_or(false) {
302//! serde_json::json!("[REDACTED - TOO LONG]")
303//! } else {
304//! val.clone()
305//! }
306//! })?
307//! .finish()
308//! .await;
309//! # Ok(())
310//! # }
311//! ```
312//!
313//! ## Handling Optional Fields
314//!
315//! By default, `redact` returns an error if the path matches nothing.
316//! Use `RedactOptions` to allow empty matches for optional fields:
317//!
318#![cfg_attr(feature = "redaction", doc = "```rust,no_run")]
319#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
320//! use clawspec_core::RedactOptions;
321//! # use clawspec_core::ApiClient;
322//! # use serde::Deserialize;
323//! # use utoipa::ToSchema;
324//! # #[derive(Deserialize, ToSchema)]
325//! # struct Response { optional_field: Option<String> }
326//! # #[tokio::main]
327//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
328//! # let mut client = ApiClient::builder().build()?;
329//!
330//! let options = RedactOptions { allow_empty_match: true };
331//!
332//! let result = client.get("/data")?
333//! .await?
334//! .as_json_redacted::<Response>()
335//! .await?
336//! // Won't error if the path doesn't exist
337//! .redact_with_options("$.optional_field", "redacted", options)?
338//! .finish()
339//! .await;
340//! # Ok(())
341//! # }
342//! ```
343//!
344//! ## The RedactedResult
345//!
346//! The `finish()` method returns a `RedactedResult`:
347//!
348#![cfg_attr(feature = "redaction", doc = "```rust,no_run")]
349#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
350//! # use clawspec_core::ApiClient;
351//! # use serde::Deserialize;
352//! # use utoipa::ToSchema;
353//! # #[derive(Debug, Deserialize, ToSchema)]
354//! # struct User { id: String, name: String }
355//! # #[tokio::main]
356//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
357//! # let mut client = ApiClient::builder().build()?;
358//! # let result = client.get("/users/1")?.await?.as_json_redacted::<User>().await?
359//! # .redact("/id", "user-001")?.finish().await;
360//! // result.value: The deserialized struct with REAL values
361//! let user: User = result.value;
362//! println!("Real ID: {}", user.id); // e.g., "550e8400-e29b-..."
363//!
364//! // result.redacted: JSON with STABLE values (used in OpenAPI)
365//! let json: serde_json::Value = result.redacted;
366//! println!("Redacted: {}", json["id"]); // "user-001"
367//! # Ok(())
368//! # }
369//! ```
370//!
371//! ## Common Patterns
372//!
373//! ### UUIDs
374//!
375#![cfg_attr(feature = "redaction", doc = "```rust,no_run")]
376#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
377//! # use clawspec_core::ApiClient;
378//! # use serde::Deserialize;
379//! # use utoipa::ToSchema;
380//! # #[derive(Deserialize, ToSchema)]
381//! # struct Entity { id: String }
382//! # #[tokio::main]
383//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
384//! # let mut client = ApiClient::builder().build()?;
385//! # let builder = client.get("/test")?.await?.as_json_redacted::<Entity>().await?;
386//! // Use a recognizable placeholder format
387//! builder.redact("/id", "00000000-0000-0000-0000-000000000001")?
388//! # .finish().await;
389//! # Ok(())
390//! # }
391//! ```
392//!
393//! ### Timestamps
394//!
395#![cfg_attr(feature = "redaction", doc = "```rust,no_run")]
396#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
397//! # use clawspec_core::ApiClient;
398//! # use serde::Deserialize;
399//! # use utoipa::ToSchema;
400//! # #[derive(Deserialize, ToSchema)]
401//! # struct Entity { created_at: String, updated_at: String }
402//! # #[tokio::main]
403//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
404//! # let mut client = ApiClient::builder().build()?;
405//! # let builder = client.get("/test")?.await?.as_json_redacted::<Entity>().await?;
406//! // Use ISO 8601 format with a memorable date
407//! builder
408//! .redact("/created_at", "2024-01-01T00:00:00Z")?
409//! .redact("/updated_at", "2024-01-01T12:00:00Z")?
410//! # .finish().await;
411//! # Ok(())
412//! # }
413//! ```
414//!
415//! ### Tokens and Secrets
416//!
417#![cfg_attr(feature = "redaction", doc = "```rust,no_run")]
418#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
419//! # use clawspec_core::ApiClient;
420//! # use serde::Deserialize;
421//! # use utoipa::ToSchema;
422//! # #[derive(Deserialize, ToSchema)]
423//! # struct AuthResponse { access_token: String, refresh_token: String }
424//! # #[tokio::main]
425//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
426//! # let mut client = ApiClient::builder().build()?;
427//! # let builder = client.get("/test")?.await?.as_json_redacted::<AuthResponse>().await?;
428//! // Use descriptive placeholders
429//! builder
430//! .redact("/access_token", "[ACCESS_TOKEN]")?
431//! .redact("/refresh_token", "[REFRESH_TOKEN]")?
432//! # .finish().await;
433//! # Ok(())
434//! # }
435//! ```
436//!
437//! ## Key Points
438//!
439//! - Enable with `features = ["redaction"]` in Cargo.toml
440//! - Use `as_json_redacted()` instead of `as_json()`
441//! - Paths are auto-detected:
442//! - `/...` - JSON Pointer (RFC 6901) for exact paths
443//! - `$...` - JSONPath (RFC 9535) for wildcards
444//! - Redactors can be:
445//! - Static values: `"stable-value"`
446//! - Closures: `|path, val| serde_json::json!(...)`
447//! - `redact()` substitutes values, `redact_remove()` deletes them
448//! - `redact_with_options()` allows empty matches for optional fields
449//! - `finish()` returns both real values (for tests) and redacted values (for docs)
450//!
451//! Next: [Chapter 7: Test Integration][super::chapter_7] - Using TestClient for
452//! end-to-end testing.