Skip to main content

sz_rust_addons_loader/
route.rs

1//! 插件路由解析
2//!
3//! ## PHP 对齐
4//!
5//! 对齐 PHP `think\addons\Route::execute($addon, $controller, $action)` 的 URL 解析逻辑:
6//!
7//! ```php
8//! // vendor/zzstudio/think-addons/src/addons/Route.php
9//! public static function execute($addon = null, $controller = null, $action = null)
10//! {
11//!     // 1. 参数校验
12//!     if (empty($addon) || empty($controller) || empty($action)) {
13//!         throw new HttpException(500, 'addon can not be empty');
14//!     }
15//!     // 2. 读取插件信息
16//!     $info = get_addons_info($addon);
17//!     if (!$info) {
18//!         throw new HttpException(404, 'addon %s not found');
19//!     }
20//!     if (!$info['status']) {
21//!         throw new HttpException(500, 'addon %s is disabled');
22//!     }
23//!     // 3. 解析控制器类
24//!     $class = get_addons_class($addon, 'controller', $controller);
25//!     // 4. 实例化并调用方法
26//! }
27//! ```
28//!
29//! ## 路由规则
30//!
31//! 对齐 PHP `Service::boot()` 注册的路由:
32//!
33//! ```php
34//! $route->rule("addons/:addon/[:controller]/[:action]", $execute);
35//! ```
36//!
37//! ## 多级控制器点号分隔
38//!
39//! 对齐 PHP `get_addons_class` 中 `.` 处理:
40//!
41//! - `admin.Order` → `admin\Order`(末段 studly 转大驼峰)
42//! - `admin.sub.Order` → `admin\sub\Order`
43
44use std::path::PathBuf;
45
46use crate::autoload::AddonAutoload;
47use crate::error::{AddonLoaderError, AddonLoaderResult};
48use crate::registry::AddonRegistry;
49
50/// 解析后的插件路由信息
51///
52/// 对齐 PHP `Route::execute($addon, $controller, $action)` 的参数三元组。
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct AddonRoute {
55    /// 插件名(对齐 PHP `$addon`)
56    pub addon: String,
57    /// 控制器名(原始形式,可能含点号;对齐 PHP `$controller`)
58    pub controller: String,
59    /// 操作名(对齐 PHP `$action`)
60    pub action: String,
61    /// 控制器类名(解析后,对齐 PHP `get_addons_class` 返回值)
62    pub controller_class: String,
63    /// 控制器文件路径
64    pub controller_file: Option<PathBuf>,
65}
66
67impl AddonRoute {
68    /// 创建路由信息
69    pub fn new(
70        addon: impl Into<String>,
71        controller: impl Into<String>,
72        action: impl Into<String>,
73    ) -> Self {
74        let addon = addon.into();
75        let controller = controller.into();
76        let action = action.into();
77
78        // 对齐 PHP `get_addons_class($addon, 'controller', $controller)` 返回的类名
79        let controller_class = build_controller_class(&addon, &controller);
80
81        Self {
82            addon,
83            controller,
84            action,
85            controller_class,
86            controller_file: None,
87        }
88    }
89
90    /// 获取控制器文件路径(若已解析)
91    pub fn controller_file(&self) -> Option<&std::path::Path> {
92        self.controller_file.as_deref()
93    }
94}
95
96/// 解析插件路由(主入口)
97///
98/// 对齐 PHP `Route::execute($addon, $controller, $action)` 完整流程:
99///
100/// 1. 参数校验(对齐 PHP `empty($addon)` 等检查)
101/// 2. 插件存在性检查(对齐 PHP `get_addons_info($addon)` 返回 false)
102/// 3. 插件状态检查(对齐 PHP `!$info['status']` 抛 500)
103/// 4. 控制器类解析(对齐 PHP `get_addons_class($addon, 'controller', $controller)`)
104/// 5. 控制器文件存在性检查(对齐 PHP `class_exists($class)`)
105///
106/// ## 参数
107///
108/// - `url`:URL 路径(如 `/addons/operate/admin.Order/index`)
109/// - `registry`:插件注册中心
110/// - `autoload`:自动加载器
111///
112/// ## 错误
113///
114/// - `RouteParse`:URL 格式错误
115/// - `AddonNotFound`:插件不存在(对齐 PHP 404)
116/// - `AddonDisabled`:插件已禁用(对齐 PHP 500)
117/// - `ControllerNotFound`:控制器类或文件不存在(对齐 PHP 404)
118#[tracing::instrument(skip(registry, autoload))]
119pub fn parse_route(
120    url: &str,
121    registry: &AddonRegistry,
122    autoload: &AddonAutoload,
123) -> AddonLoaderResult<AddonRoute> {
124    // 1. URL 解析
125    let (addon, controller, action) =
126        parse_url_segments(url).ok_or_else(|| AddonLoaderError::RouteParse {
127            url: url.to_string(),
128            reason: "URL must be /addons/<addon>/<controller>/<action>".to_string(),
129        })?;
130
131    // 2. 参数校验(对齐 PHP `empty($addon) || empty($controller) || empty($action)`)
132    if addon.is_empty() || controller.is_empty() || action.is_empty() {
133        return Err(AddonLoaderError::RouteParse {
134            url: url.to_string(),
135            reason: "addon, controller, action cannot be empty".to_string(),
136        });
137    }
138
139    // 3. 插件存在性检查(对齐 PHP `if (!$info)` → 404)
140    let manifest = registry.get(&addon)?;
141
142    // 4. 插件状态检查(对齐 PHP `if (!$info['status'])` → 500)
143    if !manifest.is_enabled() {
144        return Err(AddonLoaderError::AddonDisabled(addon));
145    }
146
147    // 5. 构建控制器类名
148    let mut route = AddonRoute::new(addon.clone(), controller, action);
149
150    // 6. 解析控制器文件路径(对齐 PHP `get_addons_class` + `class_exists` 检查)
151    let file_path = autoload.resolve_controller(&addon, &route.controller)?;
152
153    if file_path.is_none() {
154        // 对齐 PHP `HttpException(404, 'addon controller %s not found')`
155        return Err(AddonLoaderError::ControllerNotFound(
156            route.controller.clone(),
157        ));
158    }
159
160    route.controller_file = file_path;
161
162    Ok(route)
163}
164
165/// 解析 URL 段(对齐 PHP 路由规则 `addons/:addon/[:controller]/[:action]`)
166///
167/// ## 支持的 URL 格式
168///
169/// - `/addons/operate/Order/index` → `("operate", "Order", "index")`
170/// - `addons/operate/admin.Order/index` → `("operate", "admin.Order", "index")`
171/// - `/addons/operate/Order` → `("operate", "Order", "")`(action 缺失)
172/// - `/addons/operate` → `("operate", "", "")`(controller/action 缺失)
173///
174/// ## 返回
175///
176/// - `Some((addon, controller, action))`:URL 以 `addons/` 开头
177/// - `None`:URL 不以 `addons/` 开头
178fn parse_url_segments(url: &str) -> Option<(String, String, String)> {
179    // 去除前导斜杠
180    let url = url.trim_start_matches('/');
181
182    // 必须以 addons/ 开头
183    if !url.starts_with("addons/") {
184        return None;
185    }
186
187    let rest = &url["addons/".len()..];
188
189    // 拆分段
190    let parts: Vec<&str> = rest.split('/').collect();
191
192    let addon = parts.first().unwrap_or(&"").to_string();
193    let controller = parts.get(1).unwrap_or(&"").to_string();
194    let action = parts.get(2).unwrap_or(&"").to_string();
195
196    Some((addon, controller, action))
197}
198
199/// 构建控制器完整类名(对齐 PHP `get_addons_class($name, 'controller', $class)`)
200///
201/// ## PHP 对齐
202///
203/// PHP `get_addons_class` 中 `.` 处理逻辑:
204/// - 若 `$class` 含 `.`,按 `.` 拆分,末段 `Str::studly` 转大驼峰,再用 `\` 拼回
205/// - 单级时 `Str::studly(is_null($class) ? $name : $class)`
206/// - type='controller' 返回 `\addons\{name}\controller\{class}`
207/// - type='hook'(默认)返回 `\addons\{name}\Plugin`
208///
209/// ## 示例
210///
211/// - `build_controller_class("operate", "Order")` → `addons\operate\controller\Order`
212/// - `build_controller_class("operate", "admin.Order")` → `addons\operate\controller\admin\Order`
213fn build_controller_class(addon: &str, controller: &str) -> String {
214    let resolved = parse_dotted_controller(controller);
215    format!("addons\\{}\\controller\\{}", addon, resolved)
216}
217
218/// 解析多级控制器点号分隔(对齐 PHP `get_addons_class` 中 `.` 处理)
219///
220/// 直接复用 autoload 模块的同名函数。
221fn parse_dotted_controller(controller: &str) -> String {
222    if !controller.contains('.') {
223        return controller.to_string();
224    }
225
226    let mut parts: Vec<&str> = controller.split('.').collect();
227    if parts.len() == 1 {
228        return controller.to_string();
229    }
230
231    let last = parts
232        .pop()
233        .expect("已通过 contains('.') 与 len 检查保证 parts 非空");
234    let last_studly = studly_case(last);
235    parts.push(&last_studly);
236    parts.join("\\")
237}
238
239/// 下划线转大驼峰
240fn studly_case(s: &str) -> String {
241    s.split('_')
242        .map(|part| {
243            let mut chars = part.chars();
244            match chars.next() {
245                None => String::new(),
246                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
247            }
248        })
249        .collect()
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use std::fs;
256    use std::path::PathBuf;
257
258    fn make_test_env() -> (tempfile::TempDir, AddonRegistry, AddonAutoload) {
259        let tmp = tempfile::tempdir().expect("create tempdir");
260        let addons_path = tmp.path().join("addons");
261
262        // operate 插件
263        let operate_dir = addons_path.join("operate");
264        fs::create_dir_all(&operate_dir).unwrap();
265        fs::write(
266            operate_dir.join("Plugin.php"),
267            r#"
268public $info = [
269    'name' => 'operate',
270    'status' => 1,
271];
272"#,
273        )
274        .unwrap();
275
276        // operate/controller/Order.php
277        let controller_dir = operate_dir.join("controller");
278        fs::create_dir_all(&controller_dir).unwrap();
279        fs::write(controller_dir.join("Order.php"), "<?php // stub").unwrap();
280
281        // operate/controller/admin/Order.php
282        let admin_dir = controller_dir.join("admin");
283        fs::create_dir_all(&admin_dir).unwrap();
284        fs::write(admin_dir.join("Order.php"), "<?php // stub").unwrap();
285
286        // disabled 插件(status=0)
287        let disabled_dir = addons_path.join("disabled");
288        fs::create_dir_all(&disabled_dir).unwrap();
289        fs::write(
290            disabled_dir.join("Plugin.php"),
291            r#"
292public $info = [
293    'name' => 'disabled',
294    'status' => 0,
295];
296"#,
297        )
298        .unwrap();
299
300        // nonexistent 插件(无控制器)
301        let nonexistent_dir = addons_path.join("nonexistent");
302        fs::create_dir_all(&nonexistent_dir).unwrap();
303        fs::write(
304            nonexistent_dir.join("Plugin.php"),
305            r#"
306public $info = [
307    'name' => 'nonexistent',
308    'status' => 1,
309];
310"#,
311        )
312        .unwrap();
313
314        // 注册插件
315        let registry = AddonRegistry::new();
316        let _ = registry.load_from_directory(&addons_path).unwrap();
317
318        let autoload = AddonAutoload::new(&addons_path);
319
320        (tmp, registry, autoload)
321    }
322
323    #[test]
324    fn test_addon_route_new_simple() {
325        let route = AddonRoute::new("operate", "Order", "index");
326        assert_eq!(route.addon, "operate");
327        assert_eq!(route.controller, "Order");
328        assert_eq!(route.action, "index");
329        assert_eq!(route.controller_class, "addons\\operate\\controller\\Order");
330        assert!(route.controller_file.is_none());
331    }
332
333    #[test]
334    fn test_addon_route_new_dotted_controller() {
335        let route = AddonRoute::new("operate", "admin.Order", "index");
336        assert_eq!(route.controller, "admin.Order");
337        assert_eq!(
338            route.controller_class,
339            "addons\\operate\\controller\\admin\\Order"
340        );
341    }
342
343    #[test]
344    fn test_addon_route_new_three_level_dotted() {
345        let route = AddonRoute::new("operate", "admin.sub.Order", "index");
346        assert_eq!(
347            route.controller_class,
348            "addons\\operate\\controller\\admin\\sub\\Order"
349        );
350    }
351
352    #[test]
353    fn test_addon_route_controller_file_none() {
354        let route = AddonRoute::new("a", "b", "c");
355        assert!(route.controller_file().is_none());
356    }
357
358    #[test]
359    fn test_parse_url_segments_full_url() {
360        let result = parse_url_segments("/addons/operate/Order/index");
361        assert_eq!(
362            result,
363            Some((
364                "operate".to_string(),
365                "Order".to_string(),
366                "index".to_string()
367            ))
368        );
369    }
370
371    #[test]
372    fn test_parse_url_segments_no_leading_slash() {
373        let result = parse_url_segments("addons/operate/Order/index");
374        assert_eq!(
375            result,
376            Some((
377                "operate".to_string(),
378                "Order".to_string(),
379                "index".to_string()
380            ))
381        );
382    }
383
384    #[test]
385    fn test_parse_url_segments_missing_action() {
386        let result = parse_url_segments("/addons/operate/Order");
387        assert_eq!(
388            result,
389            Some(("operate".to_string(), "Order".to_string(), "".to_string()))
390        );
391    }
392
393    #[test]
394    fn test_parse_url_segments_missing_controller_and_action() {
395        let result = parse_url_segments("/addons/operate");
396        assert_eq!(
397            result,
398            Some(("operate".to_string(), "".to_string(), "".to_string()))
399        );
400    }
401
402    #[test]
403    fn test_parse_url_segments_dotted_controller() {
404        let result = parse_url_segments("/addons/operate/admin.Order/index");
405        assert_eq!(
406            result,
407            Some((
408                "operate".to_string(),
409                "admin.Order".to_string(),
410                "index".to_string()
411            ))
412        );
413    }
414
415    #[test]
416    fn test_parse_url_segments_non_addons_url() {
417        let result = parse_url_segments("/api/users");
418        assert_eq!(result, None);
419    }
420
421    #[test]
422    fn test_parse_url_segments_empty_url() {
423        let result = parse_url_segments("");
424        assert_eq!(result, None);
425    }
426
427    #[test]
428    fn test_parse_url_segments_only_addons() {
429        let result = parse_url_segments("/addons/");
430        assert_eq!(
431            result,
432            Some(("".to_string(), "".to_string(), "".to_string()))
433        );
434    }
435
436    #[test]
437    fn test_parse_url_segments_trailing_slash() {
438        let result = parse_url_segments("/addons/operate/Order/index/");
439        assert_eq!(
440            result,
441            Some((
442                "operate".to_string(),
443                "Order".to_string(),
444                "index".to_string()
445            ))
446        );
447    }
448
449    #[test]
450    fn test_build_controller_class_simple() {
451        let class = build_controller_class("operate", "Order");
452        assert_eq!(class, "addons\\operate\\controller\\Order");
453    }
454
455    #[test]
456    fn test_build_controller_class_dotted() {
457        let class = build_controller_class("operate", "admin.Order");
458        assert_eq!(class, "addons\\operate\\controller\\admin\\Order");
459    }
460
461    #[test]
462    fn test_build_controller_class_three_levels() {
463        let class = build_controller_class("operate", "admin.sub.Order");
464        assert_eq!(class, "addons\\operate\\controller\\admin\\sub\\Order");
465    }
466
467    #[test]
468    fn test_parse_route_valid() {
469        let (_tmp, registry, autoload) = make_test_env();
470
471        let route = parse_route("/addons/operate/Order/index", &registry, &autoload).unwrap();
472        assert_eq!(route.addon, "operate");
473        assert_eq!(route.controller, "Order");
474        assert_eq!(route.action, "index");
475        assert!(route.controller_file.is_some());
476    }
477
478    #[test]
479    fn test_parse_route_dotted_controller() {
480        let (_tmp, registry, autoload) = make_test_env();
481
482        let route = parse_route("/addons/operate/admin.Order/index", &registry, &autoload).unwrap();
483        assert_eq!(route.controller, "admin.Order");
484        assert_eq!(
485            route.controller_class,
486            "addons\\operate\\controller\\admin\\Order"
487        );
488        assert!(route.controller_file.is_some());
489        assert!(route
490            .controller_file
491            .unwrap()
492            .to_string_lossy()
493            .contains("admin"));
494    }
495
496    #[test]
497    fn test_parse_route_non_addons_url() {
498        let (_tmp, registry, autoload) = make_test_env();
499
500        let result = parse_route("/api/users", &registry, &autoload);
501        assert!(result.is_err());
502        match result.unwrap_err() {
503            AddonLoaderError::RouteParse { .. } => {}
504            other => panic!("expected RouteParse, got {:?}", other),
505        }
506    }
507
508    #[test]
509    fn test_parse_route_empty_action() {
510        let (_tmp, registry, autoload) = make_test_env();
511
512        let result = parse_route("/addons/operate/Order", &registry, &autoload);
513        assert!(result.is_err());
514        // action 为空应该触发 RouteParse 错误
515    }
516
517    #[test]
518    fn test_parse_route_empty_controller() {
519        let (_tmp, registry, autoload) = make_test_env();
520
521        let result = parse_route("/addons/operate", &registry, &autoload);
522        assert!(result.is_err());
523    }
524
525    #[test]
526    fn test_parse_route_addon_not_found() {
527        let (_tmp, registry, autoload) = make_test_env();
528
529        let result = parse_route("/addons/ghost/Order/index", &registry, &autoload);
530        assert!(result.is_err());
531        match result.unwrap_err() {
532            AddonLoaderError::AddonNotFound(name) => assert_eq!(name, "ghost"),
533            other => panic!("expected AddonNotFound, got {:?}", other),
534        }
535    }
536
537    #[test]
538    fn test_parse_route_addon_disabled() {
539        let (_tmp, registry, autoload) = make_test_env();
540
541        let result = parse_route("/addons/disabled/Order/index", &registry, &autoload);
542        assert!(result.is_err());
543        match result.unwrap_err() {
544            AddonLoaderError::AddonDisabled(name) => assert_eq!(name, "disabled"),
545            other => panic!("expected AddonDisabled, got {:?}", other),
546        }
547    }
548
549    #[test]
550    fn test_parse_route_controller_not_found() {
551        let (_tmp, registry, autoload) = make_test_env();
552
553        // nonexistent 插件无控制器
554        let result = parse_route("/addons/nonexistent/Ghost/index", &registry, &autoload);
555        assert!(result.is_err());
556        match result.unwrap_err() {
557            AddonLoaderError::ControllerNotFound(name) => assert_eq!(name, "Ghost"),
558            other => panic!("expected ControllerNotFound, got {:?}", other),
559        }
560    }
561
562    #[test]
563    fn test_parse_route_controller_file_resolved() {
564        let (_tmp, registry, autoload) = make_test_env();
565
566        let route = parse_route("/addons/operate/Order/index", &registry, &autoload).unwrap();
567        let file = route.controller_file.unwrap();
568        assert!(file.exists());
569        assert!(file.to_string_lossy().ends_with("Order.php"));
570    }
571
572    #[test]
573    fn test_parse_route_multilevel_controller_file_resolved() {
574        let (_tmp, registry, autoload) = make_test_env();
575
576        let route = parse_route("/addons/operate/admin.Order/index", &registry, &autoload).unwrap();
577        let file = route.controller_file.unwrap();
578        assert!(file.exists());
579        assert!(file.to_string_lossy().contains("admin"));
580        assert!(file.to_string_lossy().ends_with("Order.php"));
581    }
582
583    #[test]
584    fn test_parse_route_no_leading_slash() {
585        let (_tmp, registry, autoload) = make_test_env();
586
587        let route = parse_route("addons/operate/Order/index", &registry, &autoload).unwrap();
588        assert_eq!(route.addon, "operate");
589    }
590
591    #[test]
592    fn test_addon_route_clone_eq() {
593        let r1 = AddonRoute::new("a", "b", "c");
594        let r2 = r1.clone();
595        assert_eq!(r1, r2);
596    }
597
598    #[test]
599    fn test_addon_route_with_controller_file() {
600        let mut route = AddonRoute::new("a", "b", "c");
601        route.controller_file = Some(PathBuf::from("/addons/a/controller/B.php"));
602        assert_eq!(
603            route.controller_file(),
604            Some(std::path::Path::new("/addons/a/controller/B.php"))
605        );
606    }
607
608    // 直接测试 AddonManifest 的 status 字段对路由的影响
609    #[test]
610    fn test_route_status_check_reflects_manifest() {
611        let (_tmp, registry, autoload) = make_test_env();
612
613        // operate 启用
614        assert!(registry.is_enabled("operate").unwrap());
615
616        // disabled 禁用
617        assert!(!registry.is_enabled("disabled").unwrap());
618
619        // 动态启用 disabled
620        registry.set_enabled("disabled", true).unwrap();
621        assert!(registry.is_enabled("disabled").unwrap());
622
623        // disabled 启用后仍会因控制器不存在而失败(ControllerNotFound 而非 AddonDisabled)
624        let result = parse_route("/addons/disabled/Order/index", &registry, &autoload);
625        assert!(result.is_err());
626        match result.unwrap_err() {
627            AddonLoaderError::ControllerNotFound(_) => {}
628            AddonLoaderError::AddonDisabled(_) => {
629                panic!("should be ControllerNotFound after enabling")
630            }
631            other => panic!("unexpected error: {:?}", other),
632        }
633    }
634}