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, Default)]
9pub enum SchemaQualityMode {
10    #[default]
11    Strict,
12    Permissive,
13}
14
15/// Details for a schema-quality warning emitted in permissive mode.
16#[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
24/// Ensure every operation schema is richer than an empty stub.
25/// Returns any warnings that should be emitted when permissive mode is selected.
26pub 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
94/// Indicates whether a schema provides no meaningful structure.
95pub 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}