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