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,
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}