Skip to main content

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]")?

§Request Body Redaction

The same redaction patterns work for request bodies too. When sending POST/PUT/PATCH requests with sensitive data, you can redact values in the OpenAPI documentation while sending the real data in the HTTP request.

Key principle:

  • HTTP Request: Uses the original value with real data for testing
  • OpenAPI Example: Uses the redacted value with stable placeholders

§Basic Usage

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

#[derive(Clone, Serialize, ToSchema)]
struct LoginRequest {
    username: String,
    password: String,
}

let request = LoginRequest {
    username: "alice".to_string(),
    password: "my-secret-password".to_string(),
};

// Use json_redacted() instead of json()
client
    .post("/auth/login")?
    .json_redacted(&request)?
    .redact("/password", "[REDACTED]")?
    .await?;  // Executes the HTTP request

§Redacting Multiple Fields

#[derive(Clone, Serialize, ToSchema)]
struct CreateApiKey {
    name: String,
    secret: String,
    internal_ref: String,
}

let request = CreateApiKey {
    name: "my-key".to_string(),
    secret: "sk-live-abc123def456".to_string(),
    internal_ref: "internal-id-789".to_string(),
};

client
    .post("/api-keys")?
    .json_redacted(&request)?
    .redact("/secret", "[REDACTED_SECRET]")?
    .redact_remove("/internal_ref")?  // Remove entirely from docs
    .await?;

§Using JSONPath Wildcards

For arrays, use JSONPath syntax to redact all matching fields:

#[derive(Clone, Serialize, ToSchema)]
struct BulkCreateUsers {
    users: Vec<UserData>,
}

#[derive(Clone, Serialize, ToSchema)]
struct UserData {
    name: String,
    password: String,
}

let request = BulkCreateUsers {
    users: vec![
        UserData { name: "alice".into(), password: "secret1".into() },
        UserData { name: "bob".into(), password: "secret2".into() },
    ],
};

// Redact ALL passwords in the array
client
    .post("/users/bulk")?
    .json_redacted(&request)?
    .redact("$.users[*].password", "[REDACTED]")?
    .await?;

§Response and Request Redaction Together

You can combine request body and response redaction in the same test:

#[derive(Clone, Serialize, ToSchema)]
struct CreateUserRequest {
    username: String,
    password: String,
}

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

let request = CreateUserRequest {
    username: "alice".to_string(),
    password: "secret123".to_string(),
};

// Redact request body (password hidden in docs)
let mut response = client
    .post("/users")?
    .json_redacted(&request)?
    .redact("/password", "[REDACTED]")?
    .await?;

// Redact response body (stable IDs and timestamps)
let result = response
    .as_json_redacted::<CreateUserResponse>()
    .await?
    .redact("/id", "user-00000000")?
    .redact("/created_at", "2024-01-01T00:00:00Z")?
    .finish()
    .await;

// Test assertions use real values
assert!(!result.value.id.is_empty());

§Key Points

  • Enable with features = ["redaction"] in Cargo.toml
  • Response redaction: Use as_json_redacted() instead of as_json()
  • Request body redaction: Use json_redacted() instead of 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.