Module chapter_6

Module chapter_6 

Source
Expand description

§Chapter 6: Redaction

This chapter covers the redaction feature for creating stable OpenAPI examples.

Note: This feature requires the redaction feature flag:

clawspec-core = { version = "0.2", features = ["redaction"] }

§The Problem with Dynamic Values

When generating OpenAPI examples from real API responses, dynamic values like UUIDs, timestamps, and tokens change with every test run:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "created_at": "2024-03-15T10:30:45.123Z",
  "session_token": "eyJhbGciOiJIUzI1NiIs..."
}

This causes problems:

  • Snapshot tests fail because examples change each run
  • Documentation is inconsistent across builds
  • Sensitive values might leak into docs

§Solution: Redaction

Redaction lets you replace dynamic values with stable placeholders in OpenAPI examples while preserving the real values for your test assertions.

use clawspec_core::ApiClient;
use serde::Deserialize;
use utoipa::ToSchema;

#[derive(Deserialize, ToSchema)]
struct User {
    id: String,
    name: String,
    created_at: String,
}

// Use as_json_redacted instead of as_json
let result = client
    .post("/users")?
    .json(&serde_json::json!({"name": "Alice"}))?
    .await?
    .as_json_redacted::<User>()
    .await?
    // Replace dynamic values with stable placeholders
    .redact("/id", "00000000-0000-0000-0000-000000000001")?
    .redact("/created_at", "2024-01-01T00:00:00Z")?
    .finish()
    .await;

// result.value has the REAL dynamic values for assertions
let user = result.value;
assert!(!user.id.is_empty());
assert!(!user.created_at.is_empty());

// result.redacted has the STABLE values for OpenAPI
let redacted = result.redacted;
assert_eq!(redacted["id"], "00000000-0000-0000-0000-000000000001");

§Redaction Operations

§Replace Values

Use redact to substitute a value:

let result = client.post("/auth")?
    .json(&serde_json::json!({"user": "alice"}))?
    .await?
    .as_json_redacted::<Response>()
    .await?
    .redact("/token", "[REDACTED]")?
    .redact("/timestamp", "2024-01-01T00:00:00Z")?
    .finish()
    .await;

§Remove Values

Use redact_remove to exclude a field entirely:

let result = client.get("/data")?
    .await?
    .as_json_redacted::<Response>()
    .await?
    .redact("/public_id", "id-001")?
    .redact_remove("/internal_ref")?  // Completely remove from example
    .finish()
    .await;

§Path Syntax

Paths are auto-detected based on their prefix:

  • Paths starting with / use JSON Pointer (RFC 6901) - exact paths only
  • Paths starting with $ use JSONPath (RFC 9535) - supports wildcards

§JSON Pointer Syntax

JSON Pointer (RFC 6901) uses / as a path separator for exact paths:

PointerDescription
/idTop-level field “id”
/user/nameNested field “name” inside “user”
/items/0First element of “items” array
/items/0/id“id” of first element in “items”
/foo~1barField named “foo/bar” (/ escaped as ~1)
/foo~0barField named “foo~bar” (~ escaped as ~0)

§Nested Object Example

#[derive(Deserialize, ToSchema)]
struct Order {
    id: String,
    customer: Customer,
    items: Vec<Item>,
}

#[derive(Deserialize, ToSchema)]
struct Customer {
    id: String,
    email: String,
}

#[derive(Deserialize, ToSchema)]
struct Item {
    sku: String,
    quantity: u32,
}

let result = client.get("/orders/123")?
    .await?
    .as_json_redacted::<Order>()
    .await?
    .redact("/id", "order-001")?
    .redact("/customer/id", "customer-001")?
    .redact("/customer/email", "user@example.com")?
    .redact("/items/0/sku", "SKU-001")?
    .finish()
    .await;

§JSONPath Wildcards

For arrays or deeply nested structures, use JSONPath (RFC 9535) syntax which starts with $:

JSONPathDescription
$[*].idAll id fields in root array
$.items[*].idAll id fields in items array
$..idAll id fields anywhere (recursive descent)
$[0:3]First 3 elements of root array

§Array Redaction Example

#[derive(Deserialize, ToSchema)]
struct UserList {
    users: Vec<User>,
}

#[derive(Deserialize, ToSchema)]
struct User {
    id: String,
    name: String,
    created_at: String,
}

let result = client.get("/users")?
    .await?
    .as_json_redacted::<UserList>()
    .await?
    // Redact ALL user IDs with a single call
    .redact("$.users[*].id", "stable-user-id")?
    // Redact ALL timestamps
    .redact("$.users[*].created_at", "2024-01-01T00:00:00Z")?
    .finish()
    .await;

§Function-Based Redaction

For dynamic transformations, pass a closure instead of a static value. The closure receives the concrete JSON Pointer path and current value:

§Index-Aware IDs

Create stable, distinguishable IDs based on array position:

let result = client.get("/users")?
    .await?
    .as_json_redacted::<UserList>()
    .await?
    // Closure receives path like "/users/0/id", "/users/1/id", etc.
    .redact("$.users[*].id", |path: &str, _val: &Value| {
        // Extract index from path: "/users/0/id" -> "0"
        let idx = path.split('/').nth(2).unwrap_or("0");
        serde_json::json!(format!("user-{idx}"))
    })?
    .finish()
    .await;

// Result: user-0, user-1, user-2, etc.

§Value-Based Transformation

Transform based on the current value (path can be ignored):

let result = client.get("/documents")?
    .await?
    .as_json_redacted::<Vec<Document>>()
    .await?
    // Redact long notes, keep short ones
    .redact("$[*].notes", |_path: &str, val: &Value| {
        if val.as_str().map(|s| s.len() > 50).unwrap_or(false) {
            serde_json::json!("[REDACTED - TOO LONG]")
        } else {
            val.clone()
        }
    })?
    .finish()
    .await;

§Handling Optional Fields

By default, redact returns an error if the path matches nothing. Use RedactOptions to allow empty matches for optional fields:

use clawspec_core::RedactOptions;

let options = RedactOptions { allow_empty_match: true };

let result = client.get("/data")?
    .await?
    .as_json_redacted::<Response>()
    .await?
    // Won't error if the path doesn't exist
    .redact_with_options("$.optional_field", "redacted", options)?
    .finish()
    .await;

§The RedactedResult

The finish() method returns a RedactedResult:

// result.value: The deserialized struct with REAL values
let user: User = result.value;
println!("Real ID: {}", user.id);  // e.g., "550e8400-e29b-..."

// result.redacted: JSON with STABLE values (used in OpenAPI)
let json: serde_json::Value = result.redacted;
println!("Redacted: {}", json["id"]);  // "user-001"

§Common Patterns

§UUIDs

// Use a recognizable placeholder format
builder.redact("/id", "00000000-0000-0000-0000-000000000001")?

§Timestamps

// Use ISO 8601 format with a memorable date
builder
    .redact("/created_at", "2024-01-01T00:00:00Z")?
    .redact("/updated_at", "2024-01-01T12:00:00Z")?

§Tokens and Secrets

// Use descriptive placeholders
builder
    .redact("/access_token", "[ACCESS_TOKEN]")?
    .redact("/refresh_token", "[REFRESH_TOKEN]")?

§Key Points

  • Enable with features = ["redaction"] in Cargo.toml
  • Use as_json_redacted() instead of as_json()
  • Paths are auto-detected:
    • /... - JSON Pointer (RFC 6901) for exact paths
    • $... - JSONPath (RFC 9535) for wildcards
  • Redactors can be:
    • Static values: "stable-value"
    • Closures: |path, val| serde_json::json!(...)
  • redact() substitutes values, redact_remove() deletes them
  • redact_with_options() allows empty matches for optional fields
  • finish() returns both real values (for tests) and redacted values (for docs)

Next: Chapter 7: Test Integration - Using TestClient for end-to-end testing.