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