Skip to main content

axum_apcore/scanner/
native.rs

1// Native Axum scanner — discover routes via a metadata registry.
2//
3// Unlike FastAPI, Axum's Router is type-erased and does not expose route
4// metadata at runtime. This scanner uses a compile-time metadata registry
5// populated via the `ap_handler!` macro or manual `RouteMetadata` registration.
6//
7// For OpenAPI-based scanning (where utoipa generates the spec), use the
8// "openapi" feature and `OpenAPIScanner` instead.
9
10use std::collections::HashMap;
11use std::sync::{Arc, Mutex};
12
13use apcore_toolkit::{
14    deduplicate_ids, filter_modules, infer_annotations_from_method, ScannedModule,
15};
16use async_trait::async_trait;
17
18use crate::errors::AxumApcoreError;
19use crate::scanner::AxumScanner;
20
21/// Metadata for a single Axum route, used for scanning.
22#[derive(Debug, Clone)]
23pub struct RouteMetadata {
24    /// HTTP method (GET, POST, PUT, DELETE, PATCH).
25    pub method: String,
26    /// URL path (e.g., "/api/users/:id").
27    pub path: String,
28    /// Handler function name or target string.
29    pub handler_name: String,
30    /// Human-readable description.
31    pub description: String,
32    /// Tags for grouping.
33    pub tags: Vec<String>,
34    /// JSON Schema for inputs.
35    pub input_schema: serde_json::Value,
36    /// JSON Schema for outputs.
37    pub output_schema: serde_json::Value,
38    /// Optional full documentation.
39    pub documentation: Option<String>,
40}
41
42/// Global registry for route metadata.
43///
44/// Handlers register their metadata here (via `register_route` or the
45/// `ap_handler!` macro), and the NativeAxumScanner reads it during scanning.
46static ROUTE_REGISTRY: std::sync::LazyLock<Arc<Mutex<Vec<RouteMetadata>>>> =
47    std::sync::LazyLock::new(|| Arc::new(Mutex::new(Vec::new())));
48
49/// Register route metadata for scanning.
50pub fn register_route(metadata: RouteMetadata) {
51    let mut registry = ROUTE_REGISTRY.lock().expect("route registry lock poisoned");
52    registry.push(metadata);
53}
54
55/// Clear all registered route metadata (for testing).
56pub fn clear_routes() {
57    let mut registry = ROUTE_REGISTRY.lock().expect("route registry lock poisoned");
58    registry.clear();
59}
60
61/// Get a snapshot of all registered route metadata.
62pub fn get_registered_routes() -> Vec<RouteMetadata> {
63    let registry = ROUTE_REGISTRY.lock().expect("route registry lock poisoned");
64    registry.clone()
65}
66
67/// Native scanner that reads from the compile-time route metadata registry.
68pub struct NativeAxumScanner;
69
70impl NativeAxumScanner {
71    pub fn new() -> Self {
72        Self
73    }
74
75    /// Scan from an explicit list of routes (bypasses the global registry).
76    pub fn scan_routes(
77        &self,
78        routes: &[RouteMetadata],
79        include: Option<&str>,
80        exclude: Option<&str>,
81    ) -> Result<Vec<ScannedModule>, AxumApcoreError> {
82        let modules: Vec<ScannedModule> = routes
83            .iter()
84            .map(|meta| self.metadata_to_module(meta))
85            .collect();
86
87        let modules = deduplicate_ids(modules);
88        let modules = filter_modules(&modules, include, exclude)?;
89        Ok(modules)
90    }
91
92    /// Convert a RouteMetadata into a ScannedModule.
93    fn metadata_to_module(&self, meta: &RouteMetadata) -> ScannedModule {
94        let tag = if meta.tags.is_empty() {
95            extract_tag_from_path(&meta.path)
96        } else {
97            meta.tags[0].clone()
98        };
99
100        let module_id = format!(
101            "{}.{}.{}",
102            tag,
103            meta.handler_name,
104            meta.method.to_lowercase()
105        );
106
107        let annotations = infer_annotations_from_method(&meta.method);
108
109        let mut metadata_map = HashMap::new();
110        metadata_map.insert(
111            "http_method".into(),
112            serde_json::Value::String(meta.method.clone()),
113        );
114        metadata_map.insert(
115            "url_path".into(),
116            serde_json::Value::String(meta.path.clone()),
117        );
118
119        ScannedModule {
120            module_id,
121            description: meta.description.clone(),
122            input_schema: meta.input_schema.clone(),
123            output_schema: meta.output_schema.clone(),
124            tags: meta.tags.clone(),
125            target: format!("axum::{}", meta.handler_name),
126            version: "1.0.0".into(),
127            annotations: Some(annotations),
128            documentation: meta.documentation.clone(),
129            examples: vec![],
130            metadata: metadata_map,
131            warnings: vec![],
132        }
133    }
134}
135
136impl Default for NativeAxumScanner {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142#[async_trait]
143impl AxumScanner for NativeAxumScanner {
144    async fn scan(
145        &self,
146        _app: &axum::Router,
147        include: Option<&str>,
148        exclude: Option<&str>,
149    ) -> Result<Vec<ScannedModule>, AxumApcoreError> {
150        let routes = get_registered_routes();
151
152        let modules: Vec<ScannedModule> = routes
153            .iter()
154            .map(|meta| self.metadata_to_module(meta))
155            .collect();
156
157        let modules = deduplicate_ids(modules);
158        let modules = filter_modules(&modules, include, exclude)?;
159
160        Ok(modules)
161    }
162
163    fn source_name(&self) -> &str {
164        "native"
165    }
166}
167
168/// Extract a tag from a URL path.
169///
170/// Takes the first meaningful path segment: "/api/v1/users/:id" -> "users".
171fn extract_tag_from_path(path: &str) -> String {
172    path.split('/')
173        .find(|s| !s.is_empty() && !s.starts_with(':') && *s != "api" && !s.starts_with('v'))
174        .unwrap_or("default")
175        .to_string()
176}
177
178/// Convenience macro for registering an Axum handler with apcore metadata.
179///
180/// # Example
181///
182/// ```ignore
183/// use axum_apcore::ap_handler;
184/// use serde_json::json;
185///
186/// ap_handler! {
187///     method: "GET",
188///     path: "/api/users/:id",
189///     handler: get_user,
190///     description: "Get a user by ID",
191///     tags: ["users"],
192///     input_schema: json!({"type": "object", "properties": {"id": {"type": "string"}}}),
193///     output_schema: json!({"type": "object", "properties": {"name": {"type": "string"}}}),
194/// }
195/// ```
196#[macro_export]
197macro_rules! ap_handler {
198    (
199        method: $method:expr,
200        path: $path:expr,
201        handler: $handler:ident,
202        description: $desc:expr,
203        tags: [$($tag:expr),* $(,)?],
204        input_schema: $input:expr,
205        output_schema: $output:expr $(,)?
206    ) => {
207        $crate::scanner::native::register_route($crate::scanner::native::RouteMetadata {
208            method: $method.to_string(),
209            path: $path.to_string(),
210            handler_name: stringify!($handler).to_string(),
211            description: $desc.to_string(),
212            tags: vec![$($tag.to_string()),*],
213            input_schema: $input,
214            output_schema: $output,
215            documentation: None,
216        });
217    };
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use serde_json::json;
224
225    fn sample_route() -> RouteMetadata {
226        RouteMetadata {
227            method: "GET".into(),
228            path: "/api/users/:id".into(),
229            handler_name: "get_user".into(),
230            description: "Get a user by ID".into(),
231            tags: vec!["users".into()],
232            input_schema: json!({"type": "object", "properties": {"id": {"type": "string"}}}),
233            output_schema: json!({"type": "object", "properties": {"name": {"type": "string"}}}),
234            documentation: None,
235        }
236    }
237
238    #[test]
239    fn test_metadata_to_module() {
240        let scanner = NativeAxumScanner::new();
241        let meta = sample_route();
242        let module = scanner.metadata_to_module(&meta);
243
244        assert_eq!(module.module_id, "users.get_user.get");
245        assert_eq!(module.description, "Get a user by ID");
246        assert_eq!(module.target, "axum::get_user");
247        assert!(module.annotations.as_ref().unwrap().readonly);
248        assert!(module.annotations.as_ref().unwrap().cacheable);
249    }
250
251    #[test]
252    fn test_metadata_to_module_no_tags() {
253        let scanner = NativeAxumScanner::new();
254        let meta = RouteMetadata {
255            method: "POST".into(),
256            path: "/api/tasks".into(),
257            handler_name: "create_task".into(),
258            description: "Create a task".into(),
259            tags: vec![],
260            input_schema: json!({}),
261            output_schema: json!({}),
262            documentation: None,
263        };
264        let module = scanner.metadata_to_module(&meta);
265        assert_eq!(module.module_id, "tasks.create_task.post");
266    }
267
268    #[test]
269    fn test_extract_tag_from_path() {
270        assert_eq!(extract_tag_from_path("/api/users/:id"), "users");
271        assert_eq!(extract_tag_from_path("/api/v1/tasks"), "tasks");
272        assert_eq!(extract_tag_from_path("/health"), "health");
273        assert_eq!(extract_tag_from_path("/"), "default");
274    }
275
276    #[test]
277    fn test_scan_routes_with_multiple() {
278        let scanner = NativeAxumScanner::new();
279        let routes = vec![
280            sample_route(),
281            RouteMetadata {
282                method: "DELETE".into(),
283                path: "/api/users/:id".into(),
284                handler_name: "delete_user".into(),
285                description: "Delete a user".into(),
286                tags: vec!["users".into()],
287                input_schema: json!({}),
288                output_schema: json!({}),
289                documentation: None,
290            },
291        ];
292        let modules = scanner.scan_routes(&routes, None, None).unwrap();
293
294        assert_eq!(modules.len(), 2);
295        assert_eq!(modules[0].module_id, "users.get_user.get");
296        assert_eq!(modules[1].module_id, "users.delete_user.delete");
297
298        // Verify annotations
299        assert!(modules[0].annotations.as_ref().unwrap().readonly);
300        assert!(modules[1].annotations.as_ref().unwrap().destructive);
301    }
302
303    #[test]
304    fn test_scan_routes_with_include_filter() {
305        let scanner = NativeAxumScanner::new();
306        let routes = vec![
307            sample_route(),
308            RouteMetadata {
309                method: "GET".into(),
310                path: "/api/tasks".into(),
311                handler_name: "list_tasks".into(),
312                description: "List tasks".into(),
313                tags: vec!["tasks".into()],
314                input_schema: json!({}),
315                output_schema: json!({}),
316                documentation: None,
317            },
318        ];
319        let modules = scanner.scan_routes(&routes, Some("tasks"), None).unwrap();
320
321        assert_eq!(modules.len(), 1);
322        assert_eq!(modules[0].module_id, "tasks.list_tasks.get");
323    }
324
325    #[test]
326    fn test_scan_routes_empty() {
327        let scanner = NativeAxumScanner::new();
328        let modules = scanner.scan_routes(&[], None, None).unwrap();
329        assert!(modules.is_empty());
330    }
331
332    #[test]
333    fn test_metadata_http_method_in_metadata() {
334        let scanner = NativeAxumScanner::new();
335        let meta = sample_route();
336        let module = scanner.metadata_to_module(&meta);
337        assert_eq!(module.metadata.get("http_method").unwrap(), "GET");
338        assert_eq!(module.metadata.get("url_path").unwrap(), "/api/users/:id");
339    }
340}