Skip to main content

greentic_flow/
component_schema.rs

1use crate::{
2    component_catalog::normalize_manifest_value,
3    error::{FlowError, FlowErrorLocation, Result},
4};
5use jsonschema::Draft;
6use serde_json::{Map, Value};
7use std::{
8    fs,
9    path::{Path, PathBuf},
10};
11use url::Url;
12
13const SCHEMA_GUIDANCE: &str = "Define operations[].input_schema with real JSON Schema or define dev_flows.<op> questions/schema.";
14
15#[derive(Clone)]
16pub struct SchemaResolution {
17    pub component_id: String,
18    pub operation: String,
19    pub manifest_path: PathBuf,
20    pub schema: Option<Value>,
21}
22
23impl SchemaResolution {
24    fn new(
25        component_id: String,
26        operation: String,
27        manifest_path: PathBuf,
28        schema: Option<Value>,
29    ) -> Self {
30        Self {
31            component_id,
32            operation,
33            manifest_path,
34            schema,
35        }
36    }
37}
38
39pub fn resolve_input_schema(manifest_path: &Path, operation: &str) -> Result<SchemaResolution> {
40    let text = fs::read_to_string(manifest_path).map_err(|err| FlowError::Internal {
41        message: format!("read manifest {}: {err}", manifest_path.display()),
42        location: FlowErrorLocation::at_path(manifest_path.display().to_string()),
43    })?;
44    let mut json: Value = serde_json::from_str(&text).map_err(|err| FlowError::Internal {
45        message: format!("parse manifest {}: {err}", manifest_path.display()),
46        location: FlowErrorLocation::at_path(manifest_path.display().to_string()),
47    })?;
48    normalize_manifest_value(&mut json);
49    let component_id = json
50        .get("id")
51        .and_then(Value::as_str)
52        .unwrap_or("unknown")
53        .to_string();
54    let mut schema = json
55        .get("operations")
56        .and_then(Value::as_array)
57        .and_then(|ops| {
58            ops.iter()
59                .find(|entry| matches_operation(entry, operation))
60                .and_then(schema_value)
61        });
62    if schema.is_none() {
63        schema = json.get("config_schema").cloned();
64    }
65    Ok(SchemaResolution::new(
66        component_id,
67        operation.to_string(),
68        manifest_path.to_path_buf(),
69        schema,
70    ))
71}
72
73fn matches_operation(entry: &Value, operation: &str) -> bool {
74    operation_name(entry)
75        .map(|name| name == operation)
76        .unwrap_or(false)
77}
78
79fn operation_name(entry: &Value) -> Option<&str> {
80    entry
81        .get("name")
82        .and_then(Value::as_str)
83        .or_else(|| entry.get("operation").and_then(Value::as_str))
84        .or_else(|| entry.get("id").and_then(Value::as_str))
85}
86
87fn schema_value(entry: &Value) -> Option<Value> {
88    for key in ["input_schema", "schema"] {
89        if let Some(value) = entry.get(key)
90            && !value.is_null()
91        {
92            return Some(value.clone());
93        }
94    }
95    None
96}
97
98pub fn is_effectively_empty_schema(schema: &Value) -> bool {
99    match schema {
100        Value::Null => true,
101        Value::Bool(true) => true,
102        Value::Object(map) => {
103            if map.is_empty() {
104                return true;
105            }
106            !object_schema_has_constraints(map)
107        }
108        _ => false,
109    }
110}
111
112fn object_schema_has_constraints(map: &Map<String, Value>) -> bool {
113    for (key, value) in map {
114        match key.as_str() {
115            "$schema" | "$id" | "description" | "title" | "examples" | "default" => continue,
116            "type" => {
117                if let Some(t) = value.as_str() {
118                    if t != "object" {
119                        return true;
120                    }
121                } else {
122                    return true;
123                }
124            }
125            "properties" => {
126                if let Some(props) = value.as_object() {
127                    if props.is_empty() {
128                        continue;
129                    }
130                    return true;
131                }
132                return true;
133            }
134            "required" => {
135                if let Some(arr) = value.as_array() {
136                    if arr.is_empty() {
137                        continue;
138                    }
139                } else {
140                    return true;
141                }
142                return true;
143            }
144            "additionalProperties" => match value {
145                Value::Bool(false) => return true,
146                Value::Bool(true) => continue,
147                _ => return true,
148            },
149            "patternProperties" | "dependentSchemas" | "dependentRequired" | "const" | "enum"
150            | "items" | "oneOf" | "anyOf" | "allOf" | "not" | "if" | "then" | "else"
151            | "multipleOf" | "minimum" | "maximum" | "exclusiveMinimum" | "exclusiveMaximum"
152            | "minLength" | "maxLength" | "minItems" | "maxItems" | "contains"
153            | "minProperties" | "maxProperties" | "pattern" | "format" | "$ref" | "$defs"
154            | "dependencies" => return true,
155            _ => {
156                return true;
157            }
158        }
159    }
160    false
161}
162
163pub fn validate_payload_against_schema(ctx: &SchemaResolution, payload: &Value) -> Result<()> {
164    let schema = ctx.schema.as_ref().ok_or_else(|| FlowError::Internal {
165        message: format!(
166            "component_config: schema missing for component '{}' operation '{}'",
167            ctx.component_id, ctx.operation
168        ),
169        location: FlowErrorLocation::at_path(ctx.manifest_path.display().to_string()),
170    })?;
171    let validator = jsonschema_options_with_base(Some(ctx.manifest_path.as_path()))
172        .build(schema)
173        .map_err(|err| FlowError::Internal {
174            message: format!(
175                "component_config: schema compile failed for component '{}': {err}",
176                ctx.component_id
177            ),
178            location: FlowErrorLocation::at_path(ctx.manifest_path.display().to_string()),
179        })?;
180    let mut errors = Vec::new();
181    for err in validator.iter_errors(payload) {
182        let pointer = err.instance_path().to_string();
183        let pointer = if pointer.is_empty() {
184            "/".to_string()
185        } else {
186            pointer
187        };
188        errors.push(format!(
189            "component_config: payload invalid for component '{}' operation '{}' at {pointer}: {err}",
190            ctx.component_id, ctx.operation
191        ));
192    }
193    if errors.is_empty() {
194        Ok(())
195    } else {
196        Err(FlowError::Internal {
197            message: errors.join("; "),
198            location: FlowErrorLocation::at_path(ctx.manifest_path.display().to_string()),
199        })
200    }
201}
202
203pub fn jsonschema_options_with_base(base_path: Option<&Path>) -> jsonschema::ValidationOptions {
204    let mut options = jsonschema::options().with_draft(Draft::Draft202012);
205    if let Some(base_uri) = base_uri_for_path(base_path) {
206        options = options.with_base_uri(base_uri);
207    }
208    options
209}
210
211fn base_uri_for_path(path: Option<&Path>) -> Option<String> {
212    let base_dir = path?.parent()?;
213    let canonical_dir = base_dir.canonicalize().ok()?;
214    let mut url = Url::from_directory_path(&canonical_dir).ok()?;
215    if !url.path().ends_with('/') {
216        url.set_path(&format!("{}/", url.path().trim_end_matches('/')));
217    }
218    Some(url.to_string())
219}
220
221pub fn schema_guidance() -> &'static str {
222    SCHEMA_GUIDANCE
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use serde_json::json;
229
230    #[test]
231    fn empty_object_schema_is_empty() {
232        assert!(is_effectively_empty_schema(&json!({})));
233    }
234
235    #[test]
236    fn object_schema_without_constraints_is_empty() {
237        assert!(is_effectively_empty_schema(&json!({ "type": "object" })));
238    }
239
240    #[test]
241    fn object_schema_with_property_is_not_empty() {
242        assert!(!is_effectively_empty_schema(&json!({
243            "type": "object",
244            "properties": { "name": { "type": "string" } }
245        })));
246    }
247
248    #[test]
249    fn object_schema_with_required_is_not_empty() {
250        assert!(!is_effectively_empty_schema(&json!({
251            "type": "object",
252            "required": [ "name" ]
253        })));
254    }
255
256    #[test]
257    fn object_schema_with_oneof_is_not_empty() {
258        assert!(!is_effectively_empty_schema(&json!({
259            "type": "object",
260            "oneOf": [{ "properties": { "a": { "const": 1 } } }]
261        })));
262    }
263
264    #[test]
265    fn additional_properties_false_is_not_empty() {
266        assert!(!is_effectively_empty_schema(&json!({
267            "type": "object",
268            "additionalProperties": false
269        })));
270    }
271}