use crate::error::OpError;
use crate::op::Op;
use crate::prelude::*;
use crate::{DryContext, OpMetadata, WetContext};
use async_trait::async_trait;
use jsonschema::{Draft, JSONSchema};
pub struct ValidatingWrapper<T> {
wrapped_op: Box<dyn Op<T>>,
validate_input: bool,
validate_output: bool,
}
impl<T> ValidatingWrapper<T>
where
T: Send + Sync + 'static + serde::Serialize,
{
pub fn new(op: Box<dyn Op<T>>) -> Self {
Self {
wrapped_op: op,
validate_input: true,
validate_output: true,
}
}
pub fn input_only(op: Box<dyn Op<T>>) -> Self {
Self {
wrapped_op: op,
validate_input: true,
validate_output: false,
}
}
pub fn output_only(op: Box<dyn Op<T>>) -> Self {
Self {
wrapped_op: op,
validate_input: false,
validate_output: true,
}
}
fn validate_input_schema(&self, dry: &DryContext, metadata: &OpMetadata) -> OpResult<()> {
if !self.validate_input {
return Ok(());
}
if let Some(ref schema) = metadata.input_schema {
let compiled = JSONSchema::options()
.with_draft(Draft::Draft7)
.compile(schema)
.map_err(|e| {
OpError::Context(format!("Invalid input schema for {}: {}", metadata.name, e))
})?;
let context_json = serde_json::json!(dry.values());
let validation_result = compiled.validate(&context_json);
if let Err(errors) = validation_result {
let error_messages: Vec<String> = errors
.map(|e| format!("{}: {}", e.instance_path, e))
.collect();
return Err(OpError::Context(format!(
"Input validation failed for {}: {}",
metadata.name,
error_messages.join(", ")
)));
}
}
Ok(())
}
fn validate_references_schema(&self, wet: &WetContext, metadata: &OpMetadata) -> OpResult<()> {
if let Some(ref schema) = metadata.reference_schema {
if let Some(required_refs) = schema.get("required") {
if let Some(required_array) = required_refs.as_array() {
for required_ref in required_array {
if let Some(ref_name) = required_ref.as_str() {
if !wet.contains(ref_name) {
return Err(OpError::Context(format!(
"Required reference '{}' not found in WetContext for op '{}'",
ref_name, metadata.name
)));
}
}
}
}
}
if let Some(properties) = schema.get("properties") {
if let Some(props_obj) = properties.as_object() {
for (ref_name, ref_schema) in props_obj {
if wet.contains(ref_name) {
if let Some(ref_type) = ref_schema.get("type") {
if let Some(type_str) = ref_type.as_str() {
tracing::debug!(
"Reference '{}' exists but type validation ('{}') is skipped for runtime safety",
ref_name, type_str
);
}
}
}
}
}
}
}
Ok(())
}
fn validate_output_schema(&self, output: &T, metadata: &OpMetadata) -> OpResult<()> {
if !self.validate_output {
return Ok(());
}
if let Some(ref schema) = metadata.output_schema {
let compiled = JSONSchema::options()
.with_draft(Draft::Draft7)
.compile(schema)
.map_err(|e| {
OpError::Context(format!(
"Invalid output schema for {}: {}",
metadata.name, e
))
})?;
let output_json = serde_json::to_value(output).map_err(|e| {
OpError::Context(format!("Failed to serialize output for validation: {}", e))
})?;
let validation_result = compiled.validate(&output_json);
if let Err(errors) = validation_result {
let error_messages: Vec<String> = errors
.map(|e| format!("{}: {}", e.instance_path, e))
.collect();
return Err(OpError::Context(format!(
"Output validation failed for {}: {}",
metadata.name,
error_messages.join(", ")
)));
}
}
Ok(())
}
}
#[async_trait]
impl<T> Op<T> for ValidatingWrapper<T>
where
T: Send + Sync + 'static + serde::Serialize,
{
async fn perform(&self, dry: &mut DryContext, wet: &mut WetContext) -> OpResult<T> {
let metadata = self.wrapped_op.metadata();
self.validate_input_schema(dry, &metadata)?;
self.validate_references_schema(wet, &metadata)?;
let result = self.wrapped_op.perform(dry, wet).await?;
self.validate_output_schema(&result, &metadata)?;
Ok(result)
}
fn metadata(&self) -> OpMetadata {
self.wrapped_op.metadata()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct TestOutput {
value: i32,
}
struct ValidatedOp;
#[async_trait]
impl Op<TestOutput> for ValidatedOp {
async fn perform(
&self,
dry: &mut DryContext,
_wet: &mut WetContext,
) -> OpResult<TestOutput> {
let value = dry.get_required::<i32>("value")?;
Ok(TestOutput { value })
}
fn metadata(&self) -> OpMetadata {
OpMetadata::builder("ValidatedOp")
.description("Op with schema validation")
.input_schema(json!({
"type": "object",
"properties": {
"value": { "type": "integer", "minimum": 0, "maximum": 100 }
},
"required": ["value"]
}))
.output_schema(json!({
"type": "object",
"properties": {
"value": { "type": "integer" }
},
"required": ["value"]
}))
.build()
}
}
#[tokio::test]
async fn test0038_valid_input_output() {
let validator = ValidatingWrapper::new(Box::new(ValidatedOp));
let mut dry = DryContext::new();
dry.insert("value", 42);
let mut wet = WetContext::new();
let result = validator.perform(&mut dry, &mut wet).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().value, 42);
}
#[tokio::test]
async fn test0039_invalid_input_missing_required() {
let validator = ValidatingWrapper::new(Box::new(ValidatedOp));
let mut dry = DryContext::new();
let mut wet = WetContext::new();
let result = validator.perform(&mut dry, &mut wet).await;
assert!(result.is_err());
let err = result.unwrap_err();
match err {
OpError::Context(msg) => assert!(msg.contains("Input validation failed")),
_ => panic!("Expected Context error"),
}
}
#[tokio::test]
async fn test0040_invalid_input_out_of_range() {
let validator = ValidatingWrapper::new(Box::new(ValidatedOp));
let mut dry = DryContext::new();
dry.insert("value", 150); let mut wet = WetContext::new();
let result = validator.perform(&mut dry, &mut wet).await;
assert!(result.is_err());
let err = result.unwrap_err();
match err {
OpError::Context(msg) => assert!(msg.contains("maximum")),
_ => panic!("Expected Context error"),
}
}
#[tokio::test]
async fn test0041_input_only_validation() {
struct NoOutputSchemaOp;
#[async_trait]
impl Op<i32> for NoOutputSchemaOp {
async fn perform(&self, dry: &mut DryContext, _wet: &mut WetContext) -> OpResult<i32> {
dry.get_required::<i32>("value")
}
fn metadata(&self) -> OpMetadata {
OpMetadata::builder("NoOutputSchemaOp")
.input_schema(json!({
"type": "object",
"properties": {
"value": { "type": "integer" }
},
"required": ["value"]
}))
.build()
}
}
let validator = ValidatingWrapper::input_only(Box::new(NoOutputSchemaOp));
let mut dry = DryContext::new();
dry.insert("value", 42);
let mut wet = WetContext::new();
let result = validator.perform(&mut dry, &mut wet).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), 42);
}
#[tokio::test]
async fn test0042_output_only_validation() {
struct NoInputSchemaOp;
#[async_trait]
impl Op<TestOutput> for NoInputSchemaOp {
async fn perform(
&self,
_dry: &mut DryContext,
_wet: &mut WetContext,
) -> OpResult<TestOutput> {
Ok(TestOutput { value: 99 })
}
fn metadata(&self) -> OpMetadata {
OpMetadata::builder("NoInputSchemaOp")
.output_schema(json!({
"type": "object",
"properties": {
"value": { "type": "integer", "maximum": 100 }
},
"required": ["value"]
}))
.build()
}
}
let validator = ValidatingWrapper::output_only(Box::new(NoInputSchemaOp));
let mut dry = DryContext::new();
let mut wet = WetContext::new();
let result = validator.perform(&mut dry, &mut wet).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().value, 99);
}
#[tokio::test]
async fn test0043_no_schema_validation() {
struct NoSchemaOp;
#[async_trait]
impl Op<i32> for NoSchemaOp {
async fn perform(&self, _dry: &mut DryContext, _wet: &mut WetContext) -> OpResult<i32> {
Ok(123)
}
fn metadata(&self) -> OpMetadata {
OpMetadata::builder("NoSchemaOp").build()
}
}
let validator = ValidatingWrapper::new(Box::new(NoSchemaOp));
let mut dry = DryContext::new();
let mut wet = WetContext::new();
let result = validator.perform(&mut dry, &mut wet).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), 123);
}
#[tokio::test]
async fn test0044_metadata_transparency() {
let validator = ValidatingWrapper::new(Box::new(ValidatedOp));
let metadata = validator.metadata();
assert_eq!(metadata.name, "ValidatedOp");
assert_eq!(
metadata.description,
Some("Op with schema validation".to_string())
);
assert!(metadata.input_schema.is_some());
assert!(metadata.output_schema.is_some());
}
#[tokio::test]
async fn test0045_reference_validation() {
struct ServiceRequiringOp;
#[async_trait]
impl Op<String> for ServiceRequiringOp {
async fn perform(
&self,
_dry: &mut DryContext,
wet: &mut WetContext,
) -> OpResult<String> {
let service = wet.get_required::<String>("database")?;
Ok(format!("Used service: {}", service))
}
fn metadata(&self) -> OpMetadata {
OpMetadata::builder("ServiceRequiringOp")
.reference_schema(json!({
"type": "object",
"required": ["database", "cache"],
"properties": {
"database": { "type": "string" },
"cache": { "type": "string" }
}
}))
.build()
}
}
let validator = ValidatingWrapper::new(Box::new(ServiceRequiringOp));
let mut dry = DryContext::new();
let mut wet = WetContext::new();
let result = validator.perform(&mut dry, &mut wet).await;
assert!(result.is_err());
let err = result.unwrap_err();
match err {
OpError::Context(msg) => {
assert!(msg.contains("Required reference 'database' not found"))
}
_ => panic!("Expected Context error"),
}
wet.insert_ref("database", "postgresql".to_string());
let result = validator.perform(&mut dry, &mut wet).await;
assert!(result.is_err());
let err = result.unwrap_err();
match err {
OpError::Context(msg) => assert!(msg.contains("Required reference 'cache' not found")),
_ => panic!("Expected Context error"),
}
wet.insert_ref("cache", "redis".to_string());
let result = validator.perform(&mut dry, &mut wet).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Used service: postgresql");
}
#[tokio::test]
async fn test0046_no_reference_schema() {
struct NoRefSchemaOp;
#[async_trait]
impl Op<i32> for NoRefSchemaOp {
async fn perform(&self, _dry: &mut DryContext, _wet: &mut WetContext) -> OpResult<i32> {
Ok(456)
}
fn metadata(&self) -> OpMetadata {
OpMetadata::builder("NoRefSchemaOp").build()
}
}
let validator = ValidatingWrapper::new(Box::new(NoRefSchemaOp));
let mut dry = DryContext::new();
let mut wet = WetContext::new();
let result = validator.perform(&mut dry, &mut wet).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), 456);
}
#[tokio::test]
async fn test0112_output_only_still_validates_references() {
struct RefRequiringOp;
#[async_trait]
impl Op<i32> for RefRequiringOp {
async fn perform(&self, _dry: &mut DryContext, _wet: &mut WetContext) -> OpResult<i32> {
Ok(42)
}
fn metadata(&self) -> OpMetadata {
OpMetadata::builder("RefRequiringOp")
.reference_schema(json!({
"type": "object",
"required": ["database"],
"properties": {
"database": { "type": "string" }
}
}))
.build()
}
}
let validator = ValidatingWrapper::output_only(Box::new(RefRequiringOp));
let mut dry = DryContext::new();
let mut wet = WetContext::new();
let result = validator.perform(&mut dry, &mut wet).await;
assert!(
result.is_err(),
"output_only must still validate references"
);
match result.unwrap_err() {
OpError::Context(msg) => assert!(msg.contains("database")),
e => panic!("expected Context error, got {:?}", e),
}
wet.insert_ref("database", "postgres://localhost".to_string());
let result = validator.perform(&mut dry, &mut wet).await;
assert!(
result.is_ok(),
"should succeed when required reference is present"
);
}
}