1use greentic_types::component::ComponentOperation;
2use serde_json::{Map, Value};
3
4use crate::error::ComponentError;
5use crate::manifest::ComponentManifest;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum SchemaQualityMode {
10 Strict,
11 Permissive,
12}
13
14impl Default for SchemaQualityMode {
15 fn default() -> Self {
16 Self::Strict
17 }
18}
19
20#[derive(Debug, Clone)]
22pub struct SchemaQualityWarning {
23 pub component_id: String,
24 pub operation: String,
25 pub direction: &'static str,
26 pub message: String,
27}
28
29pub fn validate_operation_schemas(
32 manifest: &ComponentManifest,
33 mode: SchemaQualityMode,
34) -> Result<Vec<SchemaQualityWarning>, ComponentError> {
35 let mut warnings = Vec::new();
36 let component_id = manifest.id.as_str().to_string();
37 for operation in &manifest.operations {
38 check_operation_schema(
39 &component_id,
40 operation,
41 SchemaDirection::Input,
42 mode,
43 &mut warnings,
44 )?;
45 check_operation_schema(
46 &component_id,
47 operation,
48 SchemaDirection::Output,
49 mode,
50 &mut warnings,
51 )?;
52 }
53 Ok(warnings)
54}
55
56fn check_operation_schema(
57 component_id: &str,
58 operation: &ComponentOperation,
59 direction: SchemaDirection,
60 mode: SchemaQualityMode,
61 warnings: &mut Vec<SchemaQualityWarning>,
62) -> Result<(), ComponentError> {
63 let schema = match direction {
64 SchemaDirection::Input => &operation.input_schema,
65 SchemaDirection::Output => &operation.output_schema,
66 };
67
68 if !is_effectively_empty_schema(schema) {
69 return Ok(());
70 }
71
72 let direction_text = direction.as_str();
73 let message = format!(
74 "component {component_id}, operation `{}`, {direction_text} schema is empty. \
75 Populate `operations[].{direction_text}_schema` with real JSON Schema (or reference `schemas/*.json`) and run \
76 `greentic-component flow update/build` afterwards.",
77 operation.name
78 );
79
80 if mode == SchemaQualityMode::Strict {
81 return Err(ComponentError::SchemaQualityEmpty {
82 component: component_id.to_string(),
83 operation: operation.name.clone(),
84 direction: direction_text,
85 suggestion: message.clone(),
86 });
87 }
88
89 warnings.push(SchemaQualityWarning {
90 component_id: component_id.to_string(),
91 operation: operation.name.clone(),
92 direction: direction_text,
93 message,
94 });
95
96 Ok(())
97}
98
99pub fn is_effectively_empty_schema(schema: &Value) -> bool {
101 match schema {
102 Value::Null => true,
103 Value::Bool(flag) => *flag,
104 Value::Object(map) => {
105 if map.is_empty() {
106 return true;
107 }
108 if let Some(type_value) = map.get("type")
109 && type_allows_object(type_value)
110 && object_schema_is_unconstrained(map)
111 {
112 return true;
113 }
114 false
115 }
116 _ => false,
117 }
118}
119
120fn type_allows_object(type_value: &Value) -> bool {
121 match type_value {
122 Value::String(str_val) => str_val == "object",
123 Value::Array(items) => items.iter().any(|item| match item {
124 Value::String(value) => value == "object",
125 _ => false,
126 }),
127 _ => false,
128 }
129}
130
131fn object_schema_is_unconstrained(map: &Map<String, Value>) -> bool {
132 if has_constraints(map) {
133 return false;
134 }
135
136 !additional_properties_disallows_all(map)
137}
138
139fn has_constraints(map: &Map<String, Value>) -> bool {
140 static CONSTRAINT_KEYS: &[&str] = &[
141 "properties",
142 "required",
143 "oneOf",
144 "anyOf",
145 "allOf",
146 "not",
147 "if",
148 "enum",
149 "const",
150 "$ref",
151 "pattern",
152 "patternProperties",
153 "items",
154 "dependentSchemas",
155 "dependentRequired",
156 "minProperties",
157 "maxProperties",
158 "minItems",
159 "maxItems",
160 ];
161
162 for &key in CONSTRAINT_KEYS {
163 if let Some(value) = map.get(key) {
164 match key {
165 "properties" => {
166 if let Value::Object(obj) = value {
167 if !obj.is_empty() {
168 return true;
169 }
170 continue;
171 }
172 }
173 "required" => {
174 if let Value::Array(arr) = value {
175 if !arr.is_empty() {
176 return true;
177 }
178 continue;
179 }
180 }
181 _ => {
182 return true;
183 }
184 }
185 }
186 }
187
188 false
189}
190
191fn additional_properties_disallows_all(map: &Map<String, Value>) -> bool {
192 matches!(
193 map.get("additionalProperties"),
194 Some(Value::Bool(false)) | Some(Value::Object(_))
195 )
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199enum SchemaDirection {
200 Input,
201 Output,
202}
203
204impl SchemaDirection {
205 fn as_str(&self) -> &'static str {
206 match self {
207 SchemaDirection::Input => "input",
208 SchemaDirection::Output => "output",
209 }
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use serde_json::json;
216
217 use super::is_effectively_empty_schema;
218
219 #[test]
220 fn empty_object_schema_is_empty() {
221 assert!(is_effectively_empty_schema(&json!({})));
222 }
223
224 #[test]
225 fn unconstrained_object_is_empty() {
226 assert!(is_effectively_empty_schema(&json!({"type": "object"})));
227 }
228
229 #[test]
230 fn constrained_object_has_properties() {
231 assert!(!is_effectively_empty_schema(&json!({
232 "type": "object",
233 "properties": {
234 "foo": { "type": "string" }
235 }
236 })));
237 }
238
239 #[test]
240 fn constrained_object_has_required() {
241 assert!(!is_effectively_empty_schema(&json!({
242 "type": "object",
243 "required": ["foo"]
244 })));
245 }
246
247 #[test]
248 fn one_of_is_not_empty() {
249 assert!(!is_effectively_empty_schema(&json!({
250 "oneOf": [
251 { "type": "string" },
252 { "type": "number" }
253 ]
254 })));
255 }
256
257 #[test]
258 fn additional_properties_false_is_not_empty() {
259 assert!(!is_effectively_empty_schema(&json!({
260 "type": "object",
261 "additionalProperties": false
262 })));
263 }
264}