use std::future::{Future, IntoFuture};
use std::pin::Pin;
use utoipa::ToSchema;
use super::RedactOptions;
use super::apply::{apply_redaction, apply_remove};
use super::redactor::Redactor;
use crate::client::call::ApiCall;
use crate::client::error::ApiClientError;
use crate::client::{CallBody, CallResult};
#[derive(derive_more::Debug)]
#[cfg_attr(docsrs, doc(cfg(feature = "redaction")))]
pub struct RequestBodyRedactionBuilder<T> {
#[debug(skip)]
value: T,
redacted: serde_json::Value,
body: CallBody,
#[debug(skip)]
api_call: ApiCall,
}
impl<T> RequestBodyRedactionBuilder<T> {
pub(crate) fn new(
value: T,
redacted: serde_json::Value,
body: CallBody,
api_call: ApiCall,
) -> Self {
Self {
value,
redacted,
body,
api_call,
}
}
pub fn redact<R: Redactor>(self, path: &str, redactor: R) -> Result<Self, ApiClientError> {
self.redact_with_options(path, redactor, RedactOptions::default())
}
pub fn redact_with_options<R: Redactor>(
mut self,
path: &str,
redactor: R,
options: RedactOptions,
) -> Result<Self, ApiClientError> {
apply_redaction(&mut self.redacted, path, redactor, options)?;
Ok(self)
}
pub fn redact_remove(self, path: &str) -> Result<Self, ApiClientError> {
self.redact_remove_with(path, RedactOptions::default())
}
pub fn redact_remove_with(
mut self,
path: &str,
options: RedactOptions,
) -> Result<Self, ApiClientError> {
apply_remove(&mut self.redacted, path, options)?;
Ok(self)
}
pub fn finish(mut self) -> Result<ApiCall, ApiClientError>
where
T: ToSchema + 'static,
{
self.body.set_example(self.redacted);
self.api_call.body = Some(self.body);
Ok(self.api_call)
}
pub fn original_value(&self) -> &T {
&self.value
}
pub fn redacted_value(&self) -> &serde_json::Value {
&self.redacted
}
}
impl<T> IntoFuture for RequestBodyRedactionBuilder<T>
where
T: ToSchema + 'static,
{
type Output = Result<CallResult, ApiClientError>;
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
fn into_future(self) -> Self::IntoFuture {
match self.finish() {
Ok(api_call) => api_call.into_future(),
Err(e) => Box::pin(async move { Err(e) }),
}
}
}
#[cfg(test)]
mod tests {
use serde::{Deserialize, Serialize};
use serde_json::json;
use utoipa::ToSchema;
use super::*;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
struct TestRequest {
username: String,
password: String,
api_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
struct NestedRequest {
user: UserInfo,
items: Vec<Item>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
struct UserInfo {
id: String,
token: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
struct Item {
id: String,
secret: String,
}
fn create_test_api_call() -> ApiCall {
let client = reqwest::Client::new();
let base_uri = "http://localhost:8080".parse().expect("valid URI");
let collector_sender = crate::client::openapi::channel::CollectorSender::dummy();
let path = crate::client::CallPath::from("/test");
let query = crate::client::CallQuery::new();
let expected_status_codes = crate::client::response::ExpectedStatusCodes::default();
let metadata = crate::client::call_parameters::OperationMetadata::default();
ApiCall {
client,
base_uri,
collector_sender,
method: http::Method::POST,
path,
query,
headers: None,
body: None,
authentication: None,
cookies: None,
expected_status_codes,
metadata,
response_description: None,
skip_collection: false,
security: None,
}
}
fn create_test_builder() -> RequestBodyRedactionBuilder<TestRequest> {
let value = TestRequest {
username: "alice".to_string(),
password: "secret123".to_string(),
api_key: "sk-live-abc123".to_string(),
};
let redacted = json!({
"username": "alice",
"password": "secret123",
"api_key": "sk-live-abc123"
});
let body = CallBody::json_without_example(&value).expect("should create body");
let api_call = create_test_api_call();
RequestBodyRedactionBuilder::new(value, redacted, body, api_call)
}
fn create_nested_builder() -> RequestBodyRedactionBuilder<NestedRequest> {
let value = NestedRequest {
user: UserInfo {
id: "user-123".to_string(),
token: "token-abc".to_string(),
},
items: vec![
Item {
id: "item-1".to_string(),
secret: "secret-1".to_string(),
},
Item {
id: "item-2".to_string(),
secret: "secret-2".to_string(),
},
],
};
let redacted = serde_json::to_value(&value).expect("should serialize");
let body = CallBody::json_without_example(&value).expect("should create body");
let api_call = create_test_api_call();
RequestBodyRedactionBuilder::new(value, redacted, body, api_call)
}
#[test]
fn should_redact_single_field() {
let builder = create_test_builder()
.redact("/password", "[REDACTED]")
.expect("redaction should succeed");
assert_eq!(
builder.redacted.get("password").and_then(|v| v.as_str()),
Some("[REDACTED]")
);
assert_eq!(
builder.redacted.get("username").and_then(|v| v.as_str()),
Some("alice")
);
assert_eq!(
builder.redacted.get("api_key").and_then(|v| v.as_str()),
Some("sk-live-abc123")
);
}
#[test]
fn should_redact_multiple_fields() {
let builder = create_test_builder()
.redact("/password", "[REDACTED]")
.and_then(|b| b.redact("/api_key", "[REDACTED]"))
.expect("redaction should succeed");
assert_eq!(
builder.redacted.get("password").and_then(|v| v.as_str()),
Some("[REDACTED]")
);
assert_eq!(
builder.redacted.get("api_key").and_then(|v| v.as_str()),
Some("[REDACTED]")
);
assert_eq!(
builder.redacted.get("username").and_then(|v| v.as_str()),
Some("alice")
);
}
#[test]
fn should_redact_with_jsonpath_wildcards() {
let builder = create_nested_builder()
.redact("$.items[*].secret", "[REDACTED]")
.expect("redaction should succeed");
let items = builder
.redacted
.get("items")
.and_then(|v| v.as_array())
.expect("should have items");
for item in items {
assert_eq!(
item.get("secret").and_then(|v| v.as_str()),
Some("[REDACTED]")
);
}
}
#[test]
fn should_redact_with_closure() {
let builder = create_test_builder()
.redact("/password", |_path: &str, _val: &serde_json::Value| {
json!("redacted-by-closure")
})
.expect("redaction should succeed");
assert_eq!(
builder.redacted.get("password").and_then(|v| v.as_str()),
Some("redacted-by-closure")
);
}
#[test]
fn should_remove_fields() {
let builder = create_test_builder()
.redact_remove("/password")
.expect("removal should succeed");
assert!(builder.redacted.get("password").is_none());
assert!(builder.redacted.get("username").is_some());
assert!(builder.redacted.get("api_key").is_some());
}
#[test]
fn should_preserve_original_value() {
let builder = create_test_builder()
.redact("/password", "[REDACTED]")
.expect("redaction should succeed");
assert_eq!(builder.original_value().password, "secret123");
assert_eq!(
builder.redacted.get("password").and_then(|v| v.as_str()),
Some("[REDACTED]")
);
}
#[test]
fn should_fail_on_invalid_path() {
let result = create_test_builder().redact("$.nonexistent", "[REDACTED]");
assert!(result.is_err());
}
#[test]
fn should_allow_empty_match_with_option() {
let options = RedactOptions {
allow_empty_match: true,
};
let result =
create_test_builder().redact_with_options("$.nonexistent", "[REDACTED]", options);
assert!(result.is_ok());
}
#[test]
fn should_access_redacted_value() {
let builder = create_test_builder();
assert_eq!(
builder
.redacted_value()
.get("password")
.and_then(|v| v.as_str()),
Some("secret123")
);
let builder = builder
.redact("/password", "[REDACTED]")
.expect("should redact");
assert_eq!(
builder
.redacted_value()
.get("password")
.and_then(|v| v.as_str()),
Some("[REDACTED]")
);
}
#[test]
fn should_finish_and_return_api_call() {
let api_call = create_test_builder()
.redact("/password", "[REDACTED]")
.and_then(|b| b.finish())
.expect("should finish");
assert!(api_call.body.is_some());
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
struct LoginRequest {
username: String,
password: String,
}
fn get_request_body_example(
openapi: &utoipa::openapi::OpenApi,
path: &str,
method: &str,
) -> Option<serde_json::Value> {
let path_item = openapi.paths.paths.get(path)?;
let operation = match method.to_uppercase().as_str() {
"POST" => path_item.post.as_ref(),
"PUT" => path_item.put.as_ref(),
"PATCH" => path_item.patch.as_ref(),
"DELETE" => path_item.delete.as_ref(),
"GET" => path_item.get.as_ref(),
_ => None,
}?;
let request_body = operation.request_body.as_ref()?;
let content = request_body.content.get("application/json")?;
content.example.clone()
}
#[tokio::test]
async fn should_send_original_value_to_server_and_use_redacted_in_openapi() {
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::client::ApiClient;
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/login"))
.and(body_json(json!({
"username": "alice",
"password": "secret123"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"status": "ok"})))
.expect(1)
.mount(&mock_server)
.await;
let uri: http::Uri = mock_server.uri().parse().expect("valid URI");
let mut client = ApiClient::builder()
.with_host(uri.host().expect("should have host"))
.with_port(uri.port_u16().expect("should have port"))
.build()
.expect("should build client");
let request = LoginRequest {
username: "alice".to_string(),
password: "secret123".to_string(),
};
client
.post("/api/login")
.expect("should create call")
.json_redacted(&request)
.expect("should set body")
.redact("/password", "[REDACTED]")
.expect("should redact")
.await
.expect("request should succeed")
.as_empty()
.await
.expect("should complete");
let openapi = client.collected_openapi().await;
let example = get_request_body_example(&openapi, "/api/login", "POST")
.expect("should have request body example");
assert_eq!(
example.get("password").and_then(|v| v.as_str()),
Some("[REDACTED]"),
"OpenAPI example should have redacted password"
);
assert_eq!(
example.get("username").and_then(|v| v.as_str()),
Some("alice"),
"OpenAPI example should have original username"
);
}
}