use std::collections::HashSet;
use serde_json::{Map, Value};
use super::id_policy::{IDPolicy, validate_id};
#[derive(Debug, Clone)]
pub struct InputProcessingConfig {
pub id_policy: IDPolicy,
pub validate_ids: bool,
pub id_field_names: HashSet<String>,
}
impl Default for InputProcessingConfig {
fn default() -> Self {
Self {
id_policy: IDPolicy::default(),
validate_ids: true,
id_field_names: Self::default_id_field_names(),
}
}
}
impl InputProcessingConfig {
fn default_id_field_names() -> HashSet<String> {
[
"id",
"userId",
"user_id",
"postId",
"post_id",
"commentId",
"comment_id",
"authorId",
"author_id",
"ownerId",
"owner_id",
"creatorId",
"creator_id",
"tenantId",
"tenant_id",
]
.iter()
.map(|s| (*s).to_string())
.collect()
}
pub fn add_id_field(&mut self, field_name: String) {
self.id_field_names.insert(field_name);
}
#[must_use]
pub fn strict_uuid() -> Self {
Self {
id_policy: IDPolicy::UUID,
validate_ids: true,
id_field_names: Self::default_id_field_names(),
}
}
#[must_use]
pub fn opaque() -> Self {
Self {
id_policy: IDPolicy::OPAQUE,
validate_ids: false, id_field_names: Self::default_id_field_names(),
}
}
}
pub fn process_variables(
variables: &Value,
config: &InputProcessingConfig,
) -> Result<Value, ProcessingError> {
if !config.validate_ids {
return Ok(variables.clone());
}
match variables {
Value::Object(obj) => {
let mut result = Map::new();
for (key, value) in obj {
let processed_value = process_value(value, config, key)?;
result.insert(key.clone(), processed_value);
}
Ok(Value::Object(result))
},
Value::Null => Ok(Value::Null),
other => Ok(other.clone()),
}
}
fn process_value(
value: &Value,
config: &InputProcessingConfig,
field_name: &str,
) -> Result<Value, ProcessingError> {
match value {
Value::String(s)
if {
let base_field = field_name.split('[').next().unwrap_or(field_name);
config.id_field_names.contains(base_field)
} =>
{
validate_id(s, config.id_policy).map_err(|e| ProcessingError {
field_path: field_name.to_string(),
reason: format!("Invalid ID value: {e}"),
})?;
Ok(Value::String(s.clone()))
},
Value::Object(obj) => {
let mut result = Map::new();
for (key, nested_value) in obj {
let processed = process_value(nested_value, config, key)?;
result.insert(key.clone(), processed);
}
Ok(Value::Object(result))
},
Value::Array(arr) => {
let processed_items: Result<Vec<_>, _> = arr
.iter()
.enumerate()
.map(|(idx, item)| {
let array_field = format!("{field_name}[{idx}]");
process_value(item, config, &array_field)
})
.collect();
Ok(Value::Array(processed_items?))
},
other => Ok(other.clone()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProcessingError {
pub field_path: String,
pub reason: String,
}
impl std::fmt::Display for ProcessingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Error in field '{}': {}", self.field_path, self.reason)
}
}
impl std::error::Error for ProcessingError {}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use serde_json::json;
use super::*;
#[test]
fn test_process_valid_uuid_id() {
let config = InputProcessingConfig::strict_uuid();
let variables = json!({
"userId": "550e8400-e29b-41d4-a716-446655440000"
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| panic!("valid UUID should pass: {e}"));
}
#[test]
fn test_process_invalid_uuid_id() {
let config = InputProcessingConfig::strict_uuid();
let variables = json!({
"userId": "invalid-id"
});
let result = process_variables(&variables, &config);
let err = result.expect_err("invalid UUID should fail validation");
assert!(
err.field_path.contains("userId"),
"expected field_path to contain 'userId', got: {}",
err.field_path
);
}
#[test]
fn test_process_multiple_ids() {
let config = InputProcessingConfig::strict_uuid();
let variables = json!({
"userId": "550e8400-e29b-41d4-a716-446655440000",
"postId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"name": "John"
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| panic!("multiple valid UUIDs should pass: {e}"));
}
#[test]
fn test_process_nested_ids() {
let config = InputProcessingConfig::strict_uuid();
let variables = json!({
"input": {
"userId": "550e8400-e29b-41d4-a716-446655440000",
"profile": {
"authorId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
}
}
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| panic!("nested valid UUIDs should pass: {e}"));
}
#[test]
fn test_process_nested_invalid_id() {
let config = InputProcessingConfig::strict_uuid();
let variables = json!({
"input": {
"userId": "550e8400-e29b-41d4-a716-446655440000",
"profile": {
"authorId": "invalid"
}
}
});
let result = process_variables(&variables, &config);
let err = result.expect_err("nested invalid UUID should fail");
assert!(
err.field_path.contains("authorId"),
"expected field_path to contain 'authorId', got: {}",
err.field_path
);
}
#[test]
fn test_process_array_of_ids() {
let config = InputProcessingConfig::strict_uuid();
let variables = json!({
"userIds": [
"550e8400-e29b-41d4-a716-446655440000",
"6ba7b810-9dad-11d1-80b4-00c04fd430c8"
]
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| panic!("array of valid UUIDs should pass: {e}"));
}
#[test]
fn test_process_array_with_invalid_id() {
let mut config = InputProcessingConfig::strict_uuid();
config.add_id_field("userIds".to_string());
let variables = json!({
"userIds": [
"550e8400-e29b-41d4-a716-446655440000",
"invalid-id"
]
});
let result = process_variables(&variables, &config);
let err = result.expect_err("array with invalid UUID should fail");
assert!(
err.field_path.contains("userIds"),
"expected field_path to contain 'userIds', got: {}",
err.field_path
);
}
#[test]
fn test_opaque_policy_accepts_any_id() {
let config = InputProcessingConfig::opaque();
let variables = json!({
"userId": "any-string-here"
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| panic!("opaque policy should accept any ID: {e}"));
}
#[test]
fn test_disabled_validation_skips_checks() {
let mut config = InputProcessingConfig::strict_uuid();
config.validate_ids = false;
let variables = json!({
"userId": "invalid-id"
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| panic!("disabled validation should skip checks: {e}"));
}
#[test]
fn test_custom_id_field_names() {
let mut config = InputProcessingConfig::strict_uuid();
config.add_id_field("customId".to_string());
let variables = json!({
"customId": "550e8400-e29b-41d4-a716-446655440000"
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| panic!("custom ID field with valid UUID should pass: {e}"));
}
#[test]
fn test_process_null_variables() {
let config = InputProcessingConfig::strict_uuid();
let result = process_variables(&Value::Null, &config);
let value = result.unwrap_or_else(|e| panic!("null variables should pass: {e}"));
assert!(value.is_null(), "expected null output, got: {value:?}");
}
#[test]
fn test_non_id_fields_pass_through() {
let config = InputProcessingConfig::strict_uuid();
let variables = json!({
"name": "not-a-uuid",
"email": "invalid-format@email",
"age": 25
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| {
panic!("non-ID fields should pass through without validation: {e}")
});
}
}