Skip to main content

ops_rs/
op_metadata.rs

1use crate::prelude::*;
2use crate::{DryContext, WetContext};
3
4use serde_json::Value as JsonValue;
5
6/// Metadata describing an op's requirements and schemas
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct OpMetadata {
9    pub name: String,
10    pub input_schema: Option<JsonValue>,
11    pub reference_schema: Option<JsonValue>,
12    pub output_schema: Option<JsonValue>,
13    pub description: Option<String>,
14}
15
16impl OpMetadata {
17    pub fn builder(name: impl Into<String>) -> OpMetadataBuilder {
18        OpMetadataBuilder {
19            name: name.into(),
20            input_schema: None,
21            reference_schema: None,
22            output_schema: None,
23            description: None,
24        }
25    }
26
27    /// Validate a dry context against the input schema
28    pub fn validate_dry_context(&self, ctx: &DryContext) -> Result<ValidationReport, OpError> {
29        if let Some(schema) = &self.input_schema {
30            let values = ctx.values();
31            let context_json = serde_json::to_value(values)?;
32            validate_against_schema(&context_json, schema)
33        } else {
34            Ok(ValidationReport::success())
35        }
36    }
37
38    /// Validate a wet context against the reference schema
39    pub fn validate_wet_context(&self, ctx: &WetContext) -> Result<ValidationReport, OpError> {
40        if let Some(schema) = &self.reference_schema {
41            // For wet context, we can only validate that required keys exist
42            // since we can't serialize the actual references
43            let mut context_keys = serde_json::Map::new();
44            for key in ctx.keys() {
45                context_keys.insert(
46                    key.clone(),
47                    serde_json::Value::String("present".to_string()),
48                );
49            }
50            let context_json = serde_json::Value::Object(context_keys);
51            validate_reference_schema(&context_json, schema)
52        } else {
53            Ok(ValidationReport::success())
54        }
55    }
56
57    /// Validate contexts together
58    pub fn validate_contexts(
59        &self,
60        dry: &DryContext,
61        wet: &WetContext,
62    ) -> Result<ValidationReport, OpError> {
63        let dry_report = self.validate_dry_context(dry)?;
64        let wet_report = self.validate_wet_context(wet)?;
65
66        Ok(ValidationReport {
67            is_valid: dry_report.is_valid && wet_report.is_valid,
68            errors: [dry_report.errors, wet_report.errors].concat(),
69            warnings: [dry_report.warnings, wet_report.warnings].concat(),
70        })
71    }
72
73    /// Validate output against the output schema
74    pub fn validate_output<T: Serialize>(&self, output: &T) -> Result<ValidationReport, OpError> {
75        if let Some(schema) = &self.output_schema {
76            let output_json = serde_json::to_value(output)?;
77            validate_against_schema(&output_json, schema)
78        } else {
79            Ok(ValidationReport::success())
80        }
81    }
82}
83
84pub struct OpMetadataBuilder {
85    name: String,
86    input_schema: Option<JsonValue>,
87    reference_schema: Option<JsonValue>,
88    output_schema: Option<JsonValue>,
89    description: Option<String>,
90}
91
92impl OpMetadataBuilder {
93    pub fn input_schema(mut self, schema: JsonValue) -> Self {
94        self.input_schema = Some(schema);
95        self
96    }
97
98    pub fn reference_schema(mut self, schema: JsonValue) -> Self {
99        self.reference_schema = Some(schema);
100        self
101    }
102
103    pub fn output_schema(mut self, schema: JsonValue) -> Self {
104        self.output_schema = Some(schema);
105        self
106    }
107
108    pub fn description(mut self, desc: impl Into<String>) -> Self {
109        self.description = Some(desc.into());
110        self
111    }
112
113    pub fn build(self) -> OpMetadata {
114        OpMetadata {
115            name: self.name,
116            input_schema: self.input_schema,
117            reference_schema: self.reference_schema,
118            output_schema: self.output_schema,
119            description: self.description,
120        }
121    }
122}
123
124/// Result of schema validation
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ValidationReport {
127    pub is_valid: bool,
128    pub errors: Vec<ValidationError>,
129    pub warnings: Vec<ValidationWarning>,
130}
131
132impl ValidationReport {
133    pub fn success() -> Self {
134        Self {
135            is_valid: true,
136            errors: vec![],
137            warnings: vec![],
138        }
139    }
140
141    pub fn is_fully_valid(&self) -> bool {
142        self.is_valid && self.warnings.is_empty()
143    }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ValidationError {
148    pub field: String,
149    pub message: String,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct ValidationWarning {
154    pub field: String,
155    pub message: String,
156}
157
158/// TriggerFuse represents a saved request to execute an op later
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct TriggerFuse {
161    pub id: String,
162    pub trigger_name: String,
163    pub dry_context: DryContext,
164    pub created_at: chrono::DateTime<chrono::Utc>,
165    pub metadata: Option<OpMetadata>,
166}
167
168impl TriggerFuse {
169    pub fn new(trigger_name: impl Into<String>) -> Self {
170        Self {
171            id: uuid::Uuid::new_v4().to_string(),
172            trigger_name: trigger_name.into(),
173            dry_context: DryContext::new(),
174            created_at: chrono::Utc::now(),
175            metadata: None,
176        }
177    }
178
179    pub fn with_data<T: Serialize>(mut self, key: impl Into<String>, value: T) -> Self {
180        self.dry_context.insert(key, value);
181        self
182    }
183
184    pub fn with_metadata(mut self, metadata: OpMetadata) -> Self {
185        self.metadata = Some(metadata);
186        self
187    }
188
189    pub fn validate_and_get_dry_context(&self) -> Result<DryContext, OpError> {
190        if let Some(metadata) = &self.metadata {
191            let report = metadata.validate_dry_context(&self.dry_context)?;
192            if !report.is_valid {
193                return Err(OpError::Context(format!(
194                    "Invalid dry context: {:?}",
195                    report.errors
196                )));
197            }
198        }
199        Ok(self.dry_context.clone())
200    }
201}
202
203// Placeholder validation functions - these would use a proper JSON Schema validator
204fn validate_against_schema(
205    value: &JsonValue,
206    schema: &JsonValue,
207) -> Result<ValidationReport, OpError> {
208    // TODO: Implement actual JSON Schema validation using jsonschema crate
209    // For now, just do basic type checking
210
211    let mut errors = vec![];
212    let warnings = vec![];
213
214    if let (Some(schema_obj), Some(value_obj)) = (schema.as_object(), value.as_object()) {
215        // Check required fields
216        if let Some(required) = schema_obj.get("required").and_then(|r| r.as_array()) {
217            for req_field in required {
218                if let Some(field_name) = req_field.as_str() {
219                    if !value_obj.contains_key(field_name) {
220                        errors.push(ValidationError {
221                            field: field_name.to_string(),
222                            message: format!("Required field '{}' is missing", field_name),
223                        });
224                    }
225                }
226            }
227        }
228    }
229
230    Ok(ValidationReport {
231        is_valid: errors.is_empty(),
232        errors,
233        warnings,
234    })
235}
236
237fn validate_reference_schema(
238    value: &JsonValue,
239    schema: &JsonValue,
240) -> Result<ValidationReport, OpError> {
241    // Special validation for reference schemas - just check that required keys exist
242    let mut errors = vec![];
243
244    if let (Some(schema_obj), Some(value_obj)) = (schema.as_object(), value.as_object()) {
245        if let Some(required) = schema_obj.get("required").and_then(|r| r.as_array()) {
246            for req_field in required {
247                if let Some(field_name) = req_field.as_str() {
248                    if !value_obj.contains_key(field_name) {
249                        errors.push(ValidationError {
250                            field: field_name.to_string(),
251                            message: format!("Required reference '{}' is missing", field_name),
252                        });
253                    }
254                }
255            }
256        }
257    }
258
259    Ok(ValidationReport {
260        is_valid: errors.is_empty(),
261        errors,
262        warnings: vec![],
263    })
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use serde_json::json;
270
271    // TEST0021: Build OpMetadata with name, description, and schemas and verify all fields are populated
272    #[test]
273    fn test0021_metadata_builder() {
274        let metadata = OpMetadata::builder("TestOp")
275            .description("A test operation")
276            .input_schema(json!({
277                "type": "object",
278                "properties": {
279                    "name": { "type": "string" }
280                },
281                "required": ["name"]
282            }))
283            .output_schema(json!({
284                "type": "string"
285            }))
286            .build();
287
288        assert_eq!(metadata.name, "TestOp");
289        assert_eq!(metadata.description.as_deref(), Some("A test operation"));
290        assert!(metadata.input_schema.is_some());
291        assert!(metadata.output_schema.is_some());
292    }
293
294    // TEST0022: Construct a TriggerFuse with data and verify the trigger name and dry context values
295    #[test]
296    fn test0022_trigger_fuse() {
297        let request = TriggerFuse::new("ProcessImage")
298            .with_data("image_path", "/tmp/test.jpg")
299            .with_data("width", 800);
300
301        assert_eq!(request.trigger_name, "ProcessImage");
302        assert_eq!(
303            request.dry_context.get::<String>("image_path").unwrap(),
304            "/tmp/test.jpg"
305        );
306        assert_eq!(request.dry_context.get::<i32>("width").unwrap(), 800);
307    }
308
309    // TEST0023: Validate a DryContext against an input schema and confirm valid/invalid reports
310    #[test]
311    fn test0023_basic_validation() {
312        let metadata = OpMetadata::builder("TestOp")
313            .input_schema(json!({
314                "type": "object",
315                "required": ["name"]
316            }))
317            .build();
318
319        let ctx = DryContext::new().with_value("name", "test");
320        let report = metadata.validate_dry_context(&ctx).unwrap();
321        assert!(report.is_valid);
322
323        let empty_ctx = DryContext::new();
324        let report = metadata.validate_dry_context(&empty_ctx).unwrap();
325        assert!(!report.is_valid);
326        assert_eq!(report.errors.len(), 1);
327    }
328}