Skip to main content

greentic_component/
schema_quality.rs

1use greentic_types::component::ComponentOperation;
2use serde_json::{Map, Value};
3
4use crate::error::ComponentError;
5use crate::manifest::ComponentManifest;
6
7/// Mode used when validating operation schemas.
8#[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/// Details for a schema-quality warning emitted in permissive mode.
21#[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
29/// Ensure every operation schema is richer than an empty stub.
30/// Returns any warnings that should be emitted when permissive mode is selected.
31pub 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
99/// Indicates whether a schema provides no meaningful structure.
100pub 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}