Skip to main content

axum_apcore/engine/
extensions.rs

1// Extension adapters for apcore — Discoverer and ModuleValidator implementations.
2//
3// Follows the same protocol-adaptation pattern as fastapi-apcore.
4
5use apcore::module::ModuleAnnotations;
6
7use crate::config::ApcoreSettings;
8use crate::errors::AxumApcoreError;
9
10/// Maximum allowed module ID length.
11const MAX_MODULE_ID_LENGTH: usize = 256;
12
13/// Reserved words that cannot be used as module IDs.
14const RESERVED_WORDS: &[&str] = &["__init__", "__main__", "apcore", "system"];
15
16/// Discovers apcore modules from YAML binding files and registered packages.
17///
18/// Implements the apcore Discoverer protocol for Axum applications.
19pub struct AxumDiscoverer {
20    settings: ApcoreSettings,
21}
22
23impl AxumDiscoverer {
24    pub fn new(settings: ApcoreSettings) -> Self {
25        Self { settings }
26    }
27
28    /// Discover modules from binding files in the configured module directory.
29    pub fn discover(&self) -> Result<Vec<DiscoveredModule>, AxumApcoreError> {
30        let mut modules = Vec::new();
31
32        let module_dir = &self.settings.module_dir;
33        if !module_dir.exists() {
34            tracing::debug!(
35                path = %module_dir.display(),
36                "Module directory does not exist, skipping discovery"
37            );
38            return Ok(modules);
39        }
40
41        let pattern = module_dir.join(&self.settings.binding_pattern);
42        let pattern_str = pattern.to_string_lossy();
43
44        let entries = glob_binding_files(&pattern_str);
45        for path in entries {
46            match load_binding_file(&path) {
47                Ok(mut discovered) => modules.append(&mut discovered),
48                Err(e) => {
49                    tracing::warn!(path = %path, error = %e, "Failed to load binding file");
50                }
51            }
52        }
53
54        tracing::info!(count = modules.len(), "Discovered modules from bindings");
55        Ok(modules)
56    }
57}
58
59/// A module discovered from binding files.
60#[derive(Debug, Clone)]
61pub struct DiscoveredModule {
62    pub module_id: String,
63    pub target: String,
64    pub description: String,
65    pub input_schema: serde_json::Value,
66    pub output_schema: serde_json::Value,
67    pub tags: Vec<String>,
68    pub annotations: ModuleAnnotations,
69}
70
71/// Validates module IDs against apcore constraints.
72pub struct AxumModuleValidator;
73
74impl AxumModuleValidator {
75    pub fn new() -> Self {
76        Self
77    }
78
79    /// Validate a module ID. Returns a list of validation errors.
80    pub fn validate(&self, module_id: &str) -> Vec<String> {
81        let mut errors = Vec::new();
82
83        if module_id.is_empty() {
84            errors.push("Module ID cannot be empty".into());
85            return errors;
86        }
87
88        if module_id.len() > MAX_MODULE_ID_LENGTH {
89            errors.push(format!(
90                "Module ID '{}' exceeds maximum length of {}",
91                module_id, MAX_MODULE_ID_LENGTH
92            ));
93        }
94
95        if RESERVED_WORDS.contains(&module_id) {
96            errors.push(format!("Module ID '{}' is a reserved word", module_id));
97        }
98
99        // Module IDs must be dot-separated alphanumeric segments
100        for segment in module_id.split('.') {
101            if segment.is_empty() {
102                errors.push(format!(
103                    "Module ID '{}' contains empty segment (double dot)",
104                    module_id
105                ));
106            }
107        }
108
109        errors
110    }
111}
112
113impl Default for AxumModuleValidator {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119/// Glob for binding files matching the given pattern.
120fn glob_binding_files(pattern: &str) -> Vec<String> {
121    // Simple glob implementation using std::fs
122    let dir = std::path::Path::new(pattern)
123        .parent()
124        .unwrap_or(std::path::Path::new("."));
125
126    let extension_pattern = std::path::Path::new(pattern)
127        .file_name()
128        .map(|f| f.to_string_lossy().to_string())
129        .unwrap_or_default();
130
131    let suffix = extension_pattern
132        .strip_prefix('*')
133        .unwrap_or(&extension_pattern);
134
135    let mut results = Vec::new();
136    if let Ok(entries) = std::fs::read_dir(dir) {
137        for entry in entries.flatten() {
138            let name = entry.file_name().to_string_lossy().to_string();
139            if name.ends_with(suffix) {
140                results.push(entry.path().to_string_lossy().to_string());
141            }
142        }
143    }
144    results
145}
146
147/// Load modules from a YAML binding file.
148fn load_binding_file(path: &str) -> Result<Vec<DiscoveredModule>, AxumApcoreError> {
149    let content = std::fs::read_to_string(path)
150        .map_err(|e| AxumApcoreError::Config(format!("Failed to read {}: {}", path, e)))?;
151
152    let value: serde_json::Value = serde_yaml::from_str(&content)
153        .map_err(|e| AxumApcoreError::Config(format!("Failed to parse {}: {}", path, e)))?;
154
155    let modules_value = value
156        .get("modules")
157        .and_then(|v| v.as_array())
158        .ok_or_else(|| {
159            AxumApcoreError::Config(format!("No 'modules' array in binding file: {}", path))
160        })?;
161
162    let mut modules = Vec::new();
163    for module_val in modules_value {
164        let module_id = module_val
165            .get("module_id")
166            .and_then(|v| v.as_str())
167            .unwrap_or("")
168            .to_string();
169
170        let target = module_val
171            .get("target")
172            .and_then(|v| v.as_str())
173            .unwrap_or("")
174            .to_string();
175
176        let description = module_val
177            .get("description")
178            .and_then(|v| v.as_str())
179            .unwrap_or("")
180            .to_string();
181
182        let input_schema = module_val
183            .get("input_schema")
184            .cloned()
185            .unwrap_or(serde_json::json!({"type": "object"}));
186
187        let output_schema = module_val
188            .get("output_schema")
189            .cloned()
190            .unwrap_or(serde_json::json!({"type": "object"}));
191
192        let tags = module_val
193            .get("tags")
194            .and_then(|v| v.as_array())
195            .map(|arr| {
196                arr.iter()
197                    .filter_map(|v| v.as_str())
198                    .map(String::from)
199                    .collect()
200            })
201            .unwrap_or_default();
202
203        modules.push(DiscoveredModule {
204            module_id,
205            target,
206            description,
207            input_schema,
208            output_schema,
209            tags,
210            annotations: ModuleAnnotations::default(),
211        });
212    }
213
214    Ok(modules)
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_validator_valid_id() {
223        let v = AxumModuleValidator::new();
224        assert!(v.validate("users.get_user.get").is_empty());
225    }
226
227    #[test]
228    fn test_validator_empty_id() {
229        let v = AxumModuleValidator::new();
230        let errors = v.validate("");
231        assert!(!errors.is_empty());
232        assert!(errors[0].contains("empty"));
233    }
234
235    #[test]
236    fn test_validator_reserved_word() {
237        let v = AxumModuleValidator::new();
238        let errors = v.validate("apcore");
239        assert!(!errors.is_empty());
240        assert!(errors[0].contains("reserved"));
241    }
242
243    #[test]
244    fn test_validator_too_long() {
245        let v = AxumModuleValidator::new();
246        let long_id = "a".repeat(300);
247        let errors = v.validate(&long_id);
248        assert!(!errors.is_empty());
249        assert!(errors[0].contains("exceeds"));
250    }
251
252    #[test]
253    fn test_validator_double_dot() {
254        let v = AxumModuleValidator::new();
255        let errors = v.validate("users..get");
256        assert!(!errors.is_empty());
257        assert!(errors[0].contains("empty segment"));
258    }
259
260    #[test]
261    fn test_load_binding_file_missing() {
262        let result = load_binding_file("/nonexistent/file.yaml");
263        assert!(result.is_err());
264    }
265}