greentic_flow/
component_schema.rs1use 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}