Skip to main content

sz_rust_addons_loader/
resource.rs

1//! 插件 RESTful 资源路由注入
2//!
3//! 借鉴 Rails 资源路由 + ThinkPHP 6 多应用机制。
4//!
5//! ## ThinkPHP 6 资源路由对齐
6//!
7//! ThinkPHP 6 `Route::resource($rule, $route)` 生成 7 个 RESTful 路由
8//! (源码:`vendor/topthink/framework/src/think/Route.php` 第 39-47 行 `$rest` 默认数组):
9//!
10//! | 动作 | HTTP 方法 | URL 后缀 | 控制器方法 |
11//! |------|-----------|----------|-----------|
12//! | index | GET | `''` | index |
13//! | create | GET | `/create` | create |
14//! | edit | GET | `/<id>/edit` | edit |
15//! | read | GET | `/<id>` | read |
16//! | save | POST | `''` | save |
17//! | update | PUT | `/<id>` | update |
18//! | delete | DELETE | `/<id>` | delete |
19//!
20//! ## addon 路由前缀注入(核心)
21//!
22//! 插件内 `Route::resource` 自动注入 addon 路由前缀:
23//!
24//! - 普通资源:`/blog` → `/addons/<addon>/<controller>`
25//! - 嵌套资源:`blog.comment` → `/addons/<addon>/blog/<blog_id>/comment`
26//!
27//! ## PHP 项目使用情况
28//!
29//! 调研结论:PHP 项目业务代码 0 处使用 `Route::resource`,addons 0 处使用,
30//! 项目使用控制器自动路由机制(URL 直接映射 `模块/控制器/方法`)。
31//! 本模块为 Rust 侧扩展能力,对齐 ThinkPHP 6 资源路由机制作为框架能力。
32
33use std::fmt;
34
35use crate::error::{AddonLoaderError, AddonLoaderResult};
36
37/// RESTful 动作枚举(对齐 ThinkPHP 6 `$rest` 默认数组的 7 个键)
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum ResourceAction {
40    /// 列表页(GET,URL 后缀 `''`)
41    Index,
42    /// 新建表单页(GET,URL 后缀 `/create`)
43    Create,
44    /// 编辑表单页(GET,URL 后缀 `/<id>/edit`)
45    Edit,
46    /// 单个资源详情(GET,URL 后缀 `/<id>`)
47    Read,
48    /// 新建保存(POST,URL 后缀 `''`)
49    Save,
50    /// 更新(PUT,URL 后缀 `/<id>`)
51    Update,
52    /// 删除(DELETE,URL 后缀 `/<id>`)
53    Delete,
54}
55
56impl ResourceAction {
57    /// 返回动作名称(对齐 ThinkPHP 6 `$rest` 数组的键名)
58    pub fn as_str(&self) -> &'static str {
59        match self {
60            ResourceAction::Index => "index",
61            ResourceAction::Create => "create",
62            ResourceAction::Edit => "edit",
63            ResourceAction::Read => "read",
64            ResourceAction::Save => "save",
65            ResourceAction::Update => "update",
66            ResourceAction::Delete => "delete",
67        }
68    }
69
70    /// 返回对应的 HTTP 方法
71    pub fn http_method(&self) -> HttpMethod {
72        match self {
73            ResourceAction::Index
74            | ResourceAction::Create
75            | ResourceAction::Edit
76            | ResourceAction::Read => HttpMethod::Get,
77            ResourceAction::Save => HttpMethod::Post,
78            ResourceAction::Update => HttpMethod::Put,
79            ResourceAction::Delete => HttpMethod::Delete,
80        }
81    }
82
83    /// 返回 URL 后缀(对齐 ThinkPHP 6 `$rest` 数组的第二元素)
84    pub fn url_suffix(&self) -> &'static str {
85        match self {
86            ResourceAction::Index | ResourceAction::Save => "",
87            ResourceAction::Create => "/create",
88            ResourceAction::Edit => "/<id>/edit",
89            ResourceAction::Read | ResourceAction::Update | ResourceAction::Delete => "/<id>",
90        }
91    }
92
93    /// 返回控制器方法名(对齐 ThinkPHP 6 `$rest` 数组的第三元素)
94    pub fn controller_method(&self) -> &'static str {
95        self.as_str()
96    }
97
98    /// 返回全部 7 个动作(按 ThinkPHP 6 `$rest` 数组顺序)
99    pub fn all() -> &'static [ResourceAction] {
100        &[
101            ResourceAction::Index,
102            ResourceAction::Create,
103            ResourceAction::Edit,
104            ResourceAction::Read,
105            ResourceAction::Save,
106            ResourceAction::Update,
107            ResourceAction::Delete,
108        ]
109    }
110
111    /// 从字符串解析动作名(便捷方法,返回 Option)
112    ///
113    /// 注:对齐 `std::str::FromStr` trait 的语义,但保留 Option 返回类型以便测试使用。
114    pub fn parse_name(name: &str) -> Option<ResourceAction> {
115        match name {
116            "index" => Some(ResourceAction::Index),
117            "create" => Some(ResourceAction::Create),
118            "edit" => Some(ResourceAction::Edit),
119            "read" => Some(ResourceAction::Read),
120            "save" => Some(ResourceAction::Save),
121            "update" => Some(ResourceAction::Update),
122            "delete" => Some(ResourceAction::Delete),
123            _ => None,
124        }
125    }
126}
127
128/// 实现 `std::str::FromStr` trait(对齐 Rust 标准库约定)
129impl std::str::FromStr for ResourceAction {
130    type Err = String;
131
132    fn from_str(s: &str) -> Result<Self, Self::Err> {
133        Self::parse_name(s).ok_or_else(|| format!("unknown ResourceAction: {}", s))
134    }
135}
136
137/// HTTP 方法
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum HttpMethod {
140    /// HTTP GET
141    Get,
142    /// HTTP POST
143    Post,
144    /// HTTP PUT
145    Put,
146    /// HTTP DELETE
147    Delete,
148    /// HTTP PATCH
149    Patch,
150}
151
152impl HttpMethod {
153    /// 返回 HTTP 方法名(大写)
154    pub fn as_str(&self) -> &'static str {
155        match self {
156            HttpMethod::Get => "GET",
157            HttpMethod::Post => "POST",
158            HttpMethod::Put => "PUT",
159            HttpMethod::Delete => "DELETE",
160            HttpMethod::Patch => "PATCH",
161        }
162    }
163}
164
165impl fmt::Display for HttpMethod {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        write!(f, "{}", self.as_str())
168    }
169}
170
171/// 资源路由条目(单个生成的路由)
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct ResourceRouteEntry {
174    /// 动作名
175    pub action: ResourceAction,
176    /// HTTP 方法
177    pub method: HttpMethod,
178    /// 完整 URL(含 addon 前缀)
179    pub url: String,
180    /// 控制器方法名
181    pub controller_method: String,
182}
183
184/// 资源路由选项(only/except 过滤,对齐 ThinkPHP 6 `Resource::only()` / `except()`)
185#[derive(Debug, Clone, Default)]
186pub struct ResourceOptions {
187    /// 仅生成指定的动作(对齐 `->only([...])`)
188    pub only: Vec<ResourceAction>,
189    /// 排除指定的动作(对齐 `->except([...])`)
190    pub except: Vec<ResourceAction>,
191}
192
193impl ResourceOptions {
194    /// 创建空选项(生成全部 7 条路由)
195    pub fn new() -> Self {
196        Self::default()
197    }
198
199    /// 设置 only 过滤
200    pub fn with_only(mut self, only: Vec<ResourceAction>) -> Self {
201        self.only = only;
202        self
203    }
204
205    /// 设置 except 过滤
206    pub fn with_except(mut self, except: Vec<ResourceAction>) -> Self {
207        self.except = except;
208        self
209    }
210
211    /// 判断指定动作是否应该生成
212    ///
213    /// 对齐 ThinkPHP 6 `Resource.php` 第 106-109 行的过滤逻辑:
214    /// `if (isset($option['only']) && !in_array($key, $option['only'])) continue;`
215    /// `if (isset($option['except']) && in_array($key, $option['except'])) continue;`
216    pub fn should_include(&self, action: ResourceAction) -> bool {
217        if !self.only.is_empty() && !self.only.contains(&action) {
218            return false;
219        }
220        if self.except.contains(&action) {
221            return false;
222        }
223        true
224    }
225}
226
227/// 生成的资源路由集合
228#[derive(Debug, Clone)]
229pub struct ResourceRoute {
230    /// 插件名
231    pub addon: String,
232    /// 控制器名(原始形式,可能含点号表示嵌套)
233    pub controller: String,
234    /// 基础 URL(含 addon 前缀,如 `/addons/operate/blog`)
235    pub base_url: String,
236    /// 生成的路由条目列表
237    pub entries: Vec<ResourceRouteEntry>,
238}
239
240impl ResourceRoute {
241    /// 获取指定动作的路由条目
242    pub fn get(&self, action: ResourceAction) -> Option<&ResourceRouteEntry> {
243        self.entries.iter().find(|e| e.action == action)
244    }
245
246    /// 返回生成的路由数量
247    pub fn len(&self) -> usize {
248        self.entries.len()
249    }
250
251    /// 是否为空
252    pub fn is_empty(&self) -> bool {
253        self.entries.is_empty()
254    }
255}
256
257/// 构建资源路由(主入口)
258///
259/// 借鉴 Rails 资源路由 + ThinkPHP 6 多应用机制,
260/// 自动注入 addon 路由前缀 `/addons/<addon>/<controller>`。
261///
262/// ## 参数
263///
264/// - `addon`:插件名(如 `"operate"`)
265/// - `controller`:控制器名(如 `"Blog"`),支持点号嵌套(如 `"blog.comment"`)
266/// - `options`:过滤选项(only/except)
267///
268/// ## 嵌套资源(对齐 ThinkPHP 6 `Resource.php` 第 89-100 行)
269///
270/// - `blog.comment` → 基础 URL `/addons/<addon>/blog/<blog_id>/comment`
271/// - 父级资源 ID 参数名默认为 `{name}_id`(对齐 PHP `$option['var'][$val] ?? $val . '_id'`)
272///
273/// ## 生成规则(对齐 ThinkPHP 6 `$rest` 默认数组)
274///
275/// 假设调用 `build_resource_routes("operate", "Blog", ResourceOptions::new())`:
276///
277/// | 动作 | HTTP 方法 | URL | 控制器方法 |
278/// |------|-----------|-----|-----------|
279/// | index | GET | `/addons/operate/Blog` | index |
280/// | create | GET | `/addons/operate/Blog/create` | create |
281/// | edit | GET | `/addons/operate/Blog/<id>/edit` | edit |
282/// | read | GET | `/addons/operate/Blog/<id>` | read |
283/// | save | POST | `/addons/operate/Blog` | save |
284/// | update | PUT | `/addons/operate/Blog/<id>` | update |
285/// | delete | DELETE | `/addons/operate/Blog/<id>` | delete |
286///
287/// ## 错误
288///
289/// - `RouteParse`:addon 或 controller 为空
290#[tracing::instrument]
291pub fn build_resource_routes(
292    addon: &str,
293    controller: &str,
294    options: &ResourceOptions,
295) -> AddonLoaderResult<ResourceRoute> {
296    if addon.is_empty() {
297        return Err(AddonLoaderError::RouteParse {
298            url: String::new(),
299            reason: "addon cannot be empty for resource route".to_string(),
300        });
301    }
302    if controller.is_empty() {
303        return Err(AddonLoaderError::RouteParse {
304            url: String::new(),
305            reason: "controller cannot be empty for resource route".to_string(),
306        });
307    }
308
309    // 构建基础 URL(自动注入 addon 前缀 + 处理嵌套资源)
310    let base_url = build_resource_base_url(addon, controller);
311
312    // 生成 7 条路由(对齐 ThinkPHP 6 `$rest` 默认数组)
313    let mut entries = Vec::new();
314    for &action in ResourceAction::all() {
315        if !options.should_include(action) {
316            continue;
317        }
318        let url = format!("{}{}", base_url, action.url_suffix());
319        entries.push(ResourceRouteEntry {
320            action,
321            method: action.http_method(),
322            url,
323            controller_method: action.controller_method().to_string(),
324        });
325    }
326
327    Ok(ResourceRoute {
328        addon: addon.to_string(),
329        controller: controller.to_string(),
330        base_url,
331        entries,
332    })
333}
334
335/// 构建资源路由基础 URL(对齐 ThinkPHP 6 嵌套资源处理)
336///
337/// ## 嵌套资源处理(对齐 `Resource.php` 第 89-100 行)
338///
339/// ```php
340/// if (strpos($rule, '.')) {
341///     $array = explode('.', $rule);
342///     $last  = array_pop($array);
343///     $item  = [];
344///     foreach ($array as $val) {
345///         $item[] = $val . '/<' . ($option['var'][$val] ?? $val . '_id') . '>';
346///     }
347///     $rule = implode('/', $item) . '/' . $last;
348/// }
349/// ```
350///
351/// ## 示例
352///
353/// - `build_resource_base_url("operate", "Blog")` → `/addons/operate/Blog`
354/// - `build_resource_base_url("operate", "blog.comment")` → `/addons/operate/blog/<blog_id>/comment`
355/// - `build_resource_base_url("operate", "blog.post.comment")` → `/addons/operate/blog/<blog_id>/post/<post_id>/comment`
356fn build_resource_base_url(addon: &str, controller: &str) -> String {
357    if !controller.contains('.') {
358        return format!("/addons/{}/{}", addon, controller);
359    }
360
361    // 嵌套资源:按点号拆分,末段为资源名,前段为父级资源
362    let mut parts: Vec<&str> = controller.split('.').collect();
363    let last = parts.pop().unwrap_or(controller);
364
365    // 对齐 PHP:父级资源段格式为 `{val}/<{val}_id>`
366    let mut segments: Vec<String> = Vec::new();
367    for val in &parts {
368        segments.push(format!("{}/<{}_id>", val, val));
369    }
370    segments.push(last.to_string());
371
372    format!("/addons/{}/{}", addon, segments.join("/"))
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    // ==================== ResourceAction 测试 ====================
380
381    #[test]
382    fn test_resource_action_as_str() {
383        assert_eq!(ResourceAction::Index.as_str(), "index");
384        assert_eq!(ResourceAction::Create.as_str(), "create");
385        assert_eq!(ResourceAction::Edit.as_str(), "edit");
386        assert_eq!(ResourceAction::Read.as_str(), "read");
387        assert_eq!(ResourceAction::Save.as_str(), "save");
388        assert_eq!(ResourceAction::Update.as_str(), "update");
389        assert_eq!(ResourceAction::Delete.as_str(), "delete");
390    }
391
392    #[test]
393    fn test_resource_action_http_method() {
394        assert_eq!(ResourceAction::Index.http_method(), HttpMethod::Get);
395        assert_eq!(ResourceAction::Create.http_method(), HttpMethod::Get);
396        assert_eq!(ResourceAction::Edit.http_method(), HttpMethod::Get);
397        assert_eq!(ResourceAction::Read.http_method(), HttpMethod::Get);
398        assert_eq!(ResourceAction::Save.http_method(), HttpMethod::Post);
399        assert_eq!(ResourceAction::Update.http_method(), HttpMethod::Put);
400        assert_eq!(ResourceAction::Delete.http_method(), HttpMethod::Delete);
401    }
402
403    #[test]
404    fn test_resource_action_url_suffix() {
405        assert_eq!(ResourceAction::Index.url_suffix(), "");
406        assert_eq!(ResourceAction::Create.url_suffix(), "/create");
407        assert_eq!(ResourceAction::Edit.url_suffix(), "/<id>/edit");
408        assert_eq!(ResourceAction::Read.url_suffix(), "/<id>");
409        assert_eq!(ResourceAction::Save.url_suffix(), "");
410        assert_eq!(ResourceAction::Update.url_suffix(), "/<id>");
411        assert_eq!(ResourceAction::Delete.url_suffix(), "/<id>");
412    }
413
414    #[test]
415    fn test_resource_action_controller_method() {
416        assert_eq!(ResourceAction::Index.controller_method(), "index");
417        assert_eq!(ResourceAction::Save.controller_method(), "save");
418        assert_eq!(ResourceAction::Delete.controller_method(), "delete");
419    }
420
421    #[test]
422    fn test_resource_action_all_returns_seven() {
423        let all = ResourceAction::all();
424        assert_eq!(all.len(), 7);
425        assert_eq!(all[0], ResourceAction::Index);
426        assert_eq!(all[1], ResourceAction::Create);
427        assert_eq!(all[2], ResourceAction::Edit);
428        assert_eq!(all[3], ResourceAction::Read);
429        assert_eq!(all[4], ResourceAction::Save);
430        assert_eq!(all[5], ResourceAction::Update);
431        assert_eq!(all[6], ResourceAction::Delete);
432    }
433
434    #[test]
435    fn test_resource_action_parse_name_valid() {
436        assert_eq!(
437            ResourceAction::parse_name("index"),
438            Some(ResourceAction::Index)
439        );
440        assert_eq!(
441            ResourceAction::parse_name("create"),
442            Some(ResourceAction::Create)
443        );
444        assert_eq!(
445            ResourceAction::parse_name("edit"),
446            Some(ResourceAction::Edit)
447        );
448        assert_eq!(
449            ResourceAction::parse_name("read"),
450            Some(ResourceAction::Read)
451        );
452        assert_eq!(
453            ResourceAction::parse_name("save"),
454            Some(ResourceAction::Save)
455        );
456        assert_eq!(
457            ResourceAction::parse_name("update"),
458            Some(ResourceAction::Update)
459        );
460        assert_eq!(
461            ResourceAction::parse_name("delete"),
462            Some(ResourceAction::Delete)
463        );
464    }
465
466    #[test]
467    fn test_resource_action_parse_name_invalid() {
468        assert_eq!(ResourceAction::parse_name("unknown"), None);
469        assert_eq!(ResourceAction::parse_name(""), None);
470        assert_eq!(ResourceAction::parse_name("INDEX"), None);
471    }
472
473    #[test]
474    fn test_resource_action_from_str_trait() {
475        // 验证 std::str::FromStr trait 实现
476        use std::str::FromStr;
477        assert_eq!(ResourceAction::from_str("index"), Ok(ResourceAction::Index));
478        assert_eq!(
479            ResourceAction::from_str("delete"),
480            Ok(ResourceAction::Delete)
481        );
482        assert!(ResourceAction::from_str("unknown").is_err());
483        assert!(ResourceAction::from_str("").is_err());
484    }
485
486    // ==================== HttpMethod 测试 ====================
487
488    #[test]
489    fn test_http_method_as_str() {
490        assert_eq!(HttpMethod::Get.as_str(), "GET");
491        assert_eq!(HttpMethod::Post.as_str(), "POST");
492        assert_eq!(HttpMethod::Put.as_str(), "PUT");
493        assert_eq!(HttpMethod::Delete.as_str(), "DELETE");
494        assert_eq!(HttpMethod::Patch.as_str(), "PATCH");
495    }
496
497    #[test]
498    fn test_http_method_display() {
499        assert_eq!(format!("{}", HttpMethod::Get), "GET");
500        assert_eq!(format!("{}", HttpMethod::Post), "POST");
501        assert_eq!(format!("{}", HttpMethod::Put), "PUT");
502        assert_eq!(format!("{}", HttpMethod::Delete), "DELETE");
503        assert_eq!(format!("{}", HttpMethod::Patch), "PATCH");
504    }
505
506    // ==================== ResourceOptions 测试 ====================
507
508    #[test]
509    fn test_resource_options_new() {
510        let opts = ResourceOptions::new();
511        assert!(opts.only.is_empty());
512        assert!(opts.except.is_empty());
513    }
514
515    #[test]
516    fn test_resource_options_with_only() {
517        let opts =
518            ResourceOptions::new().with_only(vec![ResourceAction::Index, ResourceAction::Read]);
519        assert_eq!(opts.only.len(), 2);
520        assert!(opts.except.is_empty());
521    }
522
523    #[test]
524    fn test_resource_options_with_except() {
525        let opts = ResourceOptions::new().with_except(vec![ResourceAction::Delete]);
526        assert!(opts.only.is_empty());
527        assert_eq!(opts.except.len(), 1);
528    }
529
530    #[test]
531    fn test_resource_options_with_only_and_except() {
532        let opts = ResourceOptions::new()
533            .with_only(vec![
534                ResourceAction::Index,
535                ResourceAction::Read,
536                ResourceAction::Delete,
537            ])
538            .with_except(vec![ResourceAction::Delete]);
539        assert_eq!(opts.only.len(), 3);
540        assert_eq!(opts.except.len(), 1);
541    }
542
543    #[test]
544    fn test_should_include_no_filter() {
545        let opts = ResourceOptions::new();
546        for &action in ResourceAction::all() {
547            assert!(
548                opts.should_include(action),
549                "{:?} should be included",
550                action
551            );
552        }
553    }
554
555    #[test]
556    fn test_should_include_only_filter() {
557        let opts =
558            ResourceOptions::new().with_only(vec![ResourceAction::Index, ResourceAction::Read]);
559        assert!(opts.should_include(ResourceAction::Index));
560        assert!(opts.should_include(ResourceAction::Read));
561        assert!(!opts.should_include(ResourceAction::Create));
562        assert!(!opts.should_include(ResourceAction::Edit));
563        assert!(!opts.should_include(ResourceAction::Save));
564        assert!(!opts.should_include(ResourceAction::Update));
565        assert!(!opts.should_include(ResourceAction::Delete));
566    }
567
568    #[test]
569    fn test_should_include_except_filter() {
570        let opts = ResourceOptions::new()
571            .with_except(vec![ResourceAction::Delete, ResourceAction::Create]);
572        assert!(opts.should_include(ResourceAction::Index));
573        assert!(!opts.should_include(ResourceAction::Create));
574        assert!(opts.should_include(ResourceAction::Edit));
575        assert!(opts.should_include(ResourceAction::Read));
576        assert!(opts.should_include(ResourceAction::Save));
577        assert!(opts.should_include(ResourceAction::Update));
578        assert!(!opts.should_include(ResourceAction::Delete));
579    }
580
581    #[test]
582    fn test_should_include_only_and_except_combined() {
583        // only 包含 Delete,但 except 也排除 Delete → Delete 被排除
584        let opts = ResourceOptions::new()
585            .with_only(vec![
586                ResourceAction::Index,
587                ResourceAction::Read,
588                ResourceAction::Delete,
589            ])
590            .with_except(vec![ResourceAction::Delete]);
591        assert!(opts.should_include(ResourceAction::Index));
592        assert!(opts.should_include(ResourceAction::Read));
593        assert!(!opts.should_include(ResourceAction::Delete));
594        assert!(!opts.should_include(ResourceAction::Create));
595    }
596
597    #[test]
598    fn test_should_include_empty_only_includes_all() {
599        // only 为空时不做 only 过滤(对齐 PHP `isset($option['only'])` 判断)
600        let opts = ResourceOptions::new().with_only(vec![]);
601        for &action in ResourceAction::all() {
602            assert!(opts.should_include(action));
603        }
604    }
605
606    // ==================== build_resource_base_url 测试 ====================
607
608    #[test]
609    fn test_build_resource_base_url_simple() {
610        let url = build_resource_base_url("operate", "Blog");
611        assert_eq!(url, "/addons/operate/Blog");
612    }
613
614    #[test]
615    fn test_build_resource_base_url_nested_two_levels() {
616        let url = build_resource_base_url("operate", "blog.comment");
617        assert_eq!(url, "/addons/operate/blog/<blog_id>/comment");
618    }
619
620    #[test]
621    fn test_build_resource_base_url_nested_three_levels() {
622        let url = build_resource_base_url("operate", "blog.post.comment");
623        assert_eq!(url, "/addons/operate/blog/<blog_id>/post/<post_id>/comment");
624    }
625
626    #[test]
627    fn test_build_resource_base_url_nested_four_levels() {
628        let url = build_resource_base_url("operate", "a.b.c.d");
629        assert_eq!(url, "/addons/operate/a/<a_id>/b/<b_id>/c/<c_id>/d");
630    }
631
632    // ==================== build_resource_routes 测试 ====================
633
634    #[test]
635    fn test_build_resource_routes_default_seven() {
636        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
637        assert_eq!(route.addon, "operate");
638        assert_eq!(route.controller, "Blog");
639        assert_eq!(route.base_url, "/addons/operate/Blog");
640        assert_eq!(route.len(), 7);
641        assert!(!route.is_empty());
642    }
643
644    #[test]
645    fn test_build_resource_routes_index_entry() {
646        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
647        let entry = route.get(ResourceAction::Index).unwrap();
648        assert_eq!(entry.action, ResourceAction::Index);
649        assert_eq!(entry.method, HttpMethod::Get);
650        assert_eq!(entry.url, "/addons/operate/Blog");
651        assert_eq!(entry.controller_method, "index");
652    }
653
654    #[test]
655    fn test_build_resource_routes_create_entry() {
656        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
657        let entry = route.get(ResourceAction::Create).unwrap();
658        assert_eq!(entry.action, ResourceAction::Create);
659        assert_eq!(entry.method, HttpMethod::Get);
660        assert_eq!(entry.url, "/addons/operate/Blog/create");
661        assert_eq!(entry.controller_method, "create");
662    }
663
664    #[test]
665    fn test_build_resource_routes_edit_entry() {
666        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
667        let entry = route.get(ResourceAction::Edit).unwrap();
668        assert_eq!(entry.action, ResourceAction::Edit);
669        assert_eq!(entry.method, HttpMethod::Get);
670        assert_eq!(entry.url, "/addons/operate/Blog/<id>/edit");
671        assert_eq!(entry.controller_method, "edit");
672    }
673
674    #[test]
675    fn test_build_resource_routes_read_entry() {
676        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
677        let entry = route.get(ResourceAction::Read).unwrap();
678        assert_eq!(entry.action, ResourceAction::Read);
679        assert_eq!(entry.method, HttpMethod::Get);
680        assert_eq!(entry.url, "/addons/operate/Blog/<id>");
681        assert_eq!(entry.controller_method, "read");
682    }
683
684    #[test]
685    fn test_build_resource_routes_save_entry() {
686        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
687        let entry = route.get(ResourceAction::Save).unwrap();
688        assert_eq!(entry.action, ResourceAction::Save);
689        assert_eq!(entry.method, HttpMethod::Post);
690        assert_eq!(entry.url, "/addons/operate/Blog");
691        assert_eq!(entry.controller_method, "save");
692    }
693
694    #[test]
695    fn test_build_resource_routes_update_entry() {
696        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
697        let entry = route.get(ResourceAction::Update).unwrap();
698        assert_eq!(entry.action, ResourceAction::Update);
699        assert_eq!(entry.method, HttpMethod::Put);
700        assert_eq!(entry.url, "/addons/operate/Blog/<id>");
701        assert_eq!(entry.controller_method, "update");
702    }
703
704    #[test]
705    fn test_build_resource_routes_delete_entry() {
706        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
707        let entry = route.get(ResourceAction::Delete).unwrap();
708        assert_eq!(entry.action, ResourceAction::Delete);
709        assert_eq!(entry.method, HttpMethod::Delete);
710        assert_eq!(entry.url, "/addons/operate/Blog/<id>");
711        assert_eq!(entry.controller_method, "delete");
712    }
713
714    #[test]
715    fn test_build_resource_routes_all_urls() {
716        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
717        let urls: Vec<&str> = route.entries.iter().map(|e| e.url.as_str()).collect();
718        assert_eq!(
719            urls,
720            vec![
721                "/addons/operate/Blog",
722                "/addons/operate/Blog/create",
723                "/addons/operate/Blog/<id>/edit",
724                "/addons/operate/Blog/<id>",
725                "/addons/operate/Blog",
726                "/addons/operate/Blog/<id>",
727                "/addons/operate/Blog/<id>",
728            ]
729        );
730    }
731
732    #[test]
733    fn test_build_resource_routes_all_methods() {
734        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
735        let methods: Vec<HttpMethod> = route.entries.iter().map(|e| e.method).collect();
736        assert_eq!(
737            methods,
738            vec![
739                HttpMethod::Get,
740                HttpMethod::Get,
741                HttpMethod::Get,
742                HttpMethod::Get,
743                HttpMethod::Post,
744                HttpMethod::Put,
745                HttpMethod::Delete,
746            ]
747        );
748    }
749
750    #[test]
751    fn test_build_resource_routes_only_index_read() {
752        let opts =
753            ResourceOptions::new().with_only(vec![ResourceAction::Index, ResourceAction::Read]);
754        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
755        assert_eq!(route.len(), 2);
756        assert!(route.get(ResourceAction::Index).is_some());
757        assert!(route.get(ResourceAction::Read).is_some());
758        assert!(route.get(ResourceAction::Create).is_none());
759        assert!(route.get(ResourceAction::Edit).is_none());
760        assert!(route.get(ResourceAction::Save).is_none());
761        assert!(route.get(ResourceAction::Update).is_none());
762        assert!(route.get(ResourceAction::Delete).is_none());
763    }
764
765    #[test]
766    fn test_build_resource_routes_except_delete() {
767        let opts = ResourceOptions::new().with_except(vec![ResourceAction::Delete]);
768        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
769        assert_eq!(route.len(), 6);
770        assert!(route.get(ResourceAction::Delete).is_none());
771        assert!(route.get(ResourceAction::Index).is_some());
772    }
773
774    #[test]
775    fn test_build_resource_routes_only_create_save() {
776        let opts =
777            ResourceOptions::new().with_only(vec![ResourceAction::Create, ResourceAction::Save]);
778        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
779        assert_eq!(route.len(), 2);
780        // 顺序按 only 指定?不对,按 $rest 默认数组顺序
781        assert_eq!(route.entries[0].action, ResourceAction::Create);
782        assert_eq!(route.entries[1].action, ResourceAction::Save);
783    }
784
785    #[test]
786    fn test_build_resource_routes_except_all() {
787        let opts = ResourceOptions::new().with_except(ResourceAction::all().to_vec());
788        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
789        assert_eq!(route.len(), 0);
790        assert!(route.is_empty());
791    }
792
793    #[test]
794    fn test_build_resource_routes_only_empty() {
795        let opts = ResourceOptions::new().with_only(vec![]);
796        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
797        assert_eq!(route.len(), 7);
798    }
799
800    #[test]
801    fn test_build_resource_routes_nested_resource() {
802        let route =
803            build_resource_routes("operate", "blog.comment", &ResourceOptions::new()).unwrap();
804        assert_eq!(route.base_url, "/addons/operate/blog/<blog_id>/comment");
805        assert_eq!(route.len(), 7);
806
807        let index = route.get(ResourceAction::Index).unwrap();
808        assert_eq!(index.url, "/addons/operate/blog/<blog_id>/comment");
809
810        let read = route.get(ResourceAction::Read).unwrap();
811        assert_eq!(read.url, "/addons/operate/blog/<blog_id>/comment/<id>");
812
813        let create = route.get(ResourceAction::Create).unwrap();
814        assert_eq!(create.url, "/addons/operate/blog/<blog_id>/comment/create");
815
816        let edit = route.get(ResourceAction::Edit).unwrap();
817        assert_eq!(edit.url, "/addons/operate/blog/<blog_id>/comment/<id>/edit");
818    }
819
820    #[test]
821    fn test_build_resource_routes_nested_three_levels() {
822        let route =
823            build_resource_routes("operate", "blog.post.comment", &ResourceOptions::new()).unwrap();
824        assert_eq!(
825            route.base_url,
826            "/addons/operate/blog/<blog_id>/post/<post_id>/comment"
827        );
828
829        let read = route.get(ResourceAction::Read).unwrap();
830        assert_eq!(
831            read.url,
832            "/addons/operate/blog/<blog_id>/post/<post_id>/comment/<id>"
833        );
834    }
835
836    #[test]
837    fn test_build_resource_routes_different_addon() {
838        let route = build_resource_routes("cashier", "Order", &ResourceOptions::new()).unwrap();
839        assert_eq!(route.base_url, "/addons/cashier/Order");
840        assert_eq!(route.addon, "cashier");
841        assert_eq!(route.controller, "Order");
842    }
843
844    #[test]
845    fn test_build_resource_routes_empty_addon() {
846        let result = build_resource_routes("", "Blog", &ResourceOptions::new());
847        assert!(result.is_err());
848        match result {
849            Err(AddonLoaderError::RouteParse { reason, .. }) => {
850                assert!(reason.contains("addon cannot be empty"));
851            }
852            _ => panic!("expected RouteParse error"),
853        }
854    }
855
856    #[test]
857    fn test_build_resource_routes_empty_controller() {
858        let result = build_resource_routes("operate", "", &ResourceOptions::new());
859        assert!(result.is_err());
860        match result {
861            Err(AddonLoaderError::RouteParse { reason, .. }) => {
862                assert!(reason.contains("controller cannot be empty"));
863            }
864            _ => panic!("expected RouteParse error"),
865        }
866    }
867
868    #[test]
869    fn test_build_resource_routes_get_returns_none_for_excluded() {
870        let opts = ResourceOptions::new().with_only(vec![ResourceAction::Index]);
871        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
872        assert!(route.get(ResourceAction::Index).is_some());
873        assert!(route.get(ResourceAction::Read).is_none());
874    }
875
876    #[test]
877    fn test_build_resource_routes_entries_order() {
878        // 验证生成顺序对齐 ThinkPHP 6 $rest 默认数组顺序
879        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
880        let actions: Vec<ResourceAction> = route.entries.iter().map(|e| e.action).collect();
881        assert_eq!(
882            actions,
883            vec![
884                ResourceAction::Index,
885                ResourceAction::Create,
886                ResourceAction::Edit,
887                ResourceAction::Read,
888                ResourceAction::Save,
889                ResourceAction::Update,
890                ResourceAction::Delete,
891            ]
892        );
893    }
894
895    // ==================== ResourceRoute 测试 ====================
896
897    #[test]
898    fn test_resource_route_len() {
899        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
900        assert_eq!(route.len(), 7);
901    }
902
903    #[test]
904    fn test_resource_route_is_empty_false() {
905        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
906        assert!(!route.is_empty());
907    }
908
909    #[test]
910    fn test_resource_route_is_empty_true() {
911        let opts = ResourceOptions::new().with_except(ResourceAction::all().to_vec());
912        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
913        assert!(route.is_empty());
914    }
915
916    #[test]
917    fn test_resource_route_get_existing() {
918        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
919        let entry = route.get(ResourceAction::Save).unwrap();
920        assert_eq!(entry.method, HttpMethod::Post);
921    }
922
923    #[test]
924    fn test_resource_route_get_nonexistent() {
925        let opts = ResourceOptions::new().with_only(vec![ResourceAction::Index]);
926        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
927        assert!(route.get(ResourceAction::Save).is_none());
928    }
929
930    // ==================== R5 ThinkPHP 6 行为对齐测试 ====================
931
932    #[test]
933    fn test_r5_thinkphp_rest_default_mapping() {
934        // 对齐 ThinkPHP 6 `$rest` 默认数组(Route.php 第 39-47 行)
935        // 验证 7 个动作的 HTTP 方法 + URL 后缀 + 控制器方法名
936        let expected: &[(ResourceAction, HttpMethod, &str, &str)] = &[
937            (ResourceAction::Index, HttpMethod::Get, "", "index"),
938            (ResourceAction::Create, HttpMethod::Get, "/create", "create"),
939            (ResourceAction::Edit, HttpMethod::Get, "/<id>/edit", "edit"),
940            (ResourceAction::Read, HttpMethod::Get, "/<id>", "read"),
941            (ResourceAction::Save, HttpMethod::Post, "", "save"),
942            (ResourceAction::Update, HttpMethod::Put, "/<id>", "update"),
943            (
944                ResourceAction::Delete,
945                HttpMethod::Delete,
946                "/<id>",
947                "delete",
948            ),
949        ];
950
951        for &(action, method, suffix, ctrl_method) in expected {
952            assert_eq!(
953                action.http_method(),
954                method,
955                "{:?} HTTP method mismatch",
956                action
957            );
958            assert_eq!(
959                action.url_suffix(),
960                suffix,
961                "{:?} URL suffix mismatch",
962                action
963            );
964            assert_eq!(
965                action.controller_method(),
966                ctrl_method,
967                "{:?} controller method mismatch",
968                action
969            );
970        }
971    }
972
973    #[test]
974    fn test_r5_thinkphp_resource_seven_routes() {
975        // 对齐 ThinkPHP 6 `Route::resource('blog', 'Blog')` 生成 7 条路由
976        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
977        assert_eq!(route.len(), 7, "ThinkPHP 6 resource must generate 7 routes");
978    }
979
980    #[test]
981    fn test_r5_thinkphp_only_filter() {
982        // 对齐 ThinkPHP 6 `Resource::only(['index', 'read'])` 过滤逻辑
983        let opts =
984            ResourceOptions::new().with_only(vec![ResourceAction::Index, ResourceAction::Read]);
985        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
986        assert_eq!(route.len(), 2);
987    }
988
989    #[test]
990    fn test_r5_thinkphp_except_filter() {
991        // 对齐 ThinkPHP 6 `Resource::except(['delete'])` 过滤逻辑
992        let opts = ResourceOptions::new().with_except(vec![ResourceAction::Delete]);
993        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
994        assert_eq!(route.len(), 6);
995    }
996
997    #[test]
998    fn test_r5_thinkphp_nested_resource() {
999        // 对齐 ThinkPHP 6 `Route::resource('blog.comment', 'Comment')` 嵌套资源
1000        // 父级资源 ID 参数名默认为 `{name}_id`
1001        let route =
1002            build_resource_routes("operate", "blog.comment", &ResourceOptions::new()).unwrap();
1003        assert_eq!(route.base_url, "/addons/operate/blog/<blog_id>/comment");
1004    }
1005
1006    #[test]
1007    fn test_r5_thinkphp_no_add_action() {
1008        // 对齐 ThinkPHP 6 默认映射中没有 `add` 动作
1009        // 新建资源使用 `save`(POST 到根 URL)
1010        assert!(ResourceAction::parse_name("add").is_none());
1011        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
1012        assert!(route.get(ResourceAction::Save).is_some());
1013        // 确认 save 是 POST 方法
1014        assert_eq!(
1015            route.get(ResourceAction::Save).unwrap().method,
1016            HttpMethod::Post
1017        );
1018    }
1019
1020    #[test]
1021    fn test_r5_thinkphp_update_uses_put_not_patch() {
1022        // 对齐 ThinkPHP 6 默认 `$rest` 数组中 update 使用 PUT 方法(不是 PATCH)
1023        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
1024        let update = route.get(ResourceAction::Update).unwrap();
1025        assert_eq!(update.method, HttpMethod::Put);
1026    }
1027
1028    #[test]
1029    fn test_r5_thinkphp_index_and_save_share_url() {
1030        // 对齐 ThinkPHP 6:index(GET)和 save(POST)共享相同的 URL 后缀 `''`
1031        // 通过 HTTP 方法区分
1032        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
1033        let index = route.get(ResourceAction::Index).unwrap();
1034        let save = route.get(ResourceAction::Save).unwrap();
1035        assert_eq!(index.url, save.url);
1036        assert_ne!(index.method, save.method);
1037    }
1038
1039    #[test]
1040    fn test_r5_thinkphp_read_update_delete_share_url() {
1041        // 对齐 ThinkPHP 6:read(GET)、update(PUT)、delete(DELETE)共享 URL 后缀 `/<id>`
1042        // 通过 HTTP 方法区分
1043        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
1044        let read = route.get(ResourceAction::Read).unwrap();
1045        let update = route.get(ResourceAction::Update).unwrap();
1046        let delete = route.get(ResourceAction::Delete).unwrap();
1047        assert_eq!(read.url, update.url);
1048        assert_eq!(read.url, delete.url);
1049        assert_ne!(read.method, update.method);
1050        assert_ne!(read.method, delete.method);
1051        assert_ne!(update.method, delete.method);
1052    }
1053
1054    #[test]
1055    fn test_r5_addon_prefix_injection() {
1056        // 核心:自动注入 addon 路由前缀
1057        let route = build_resource_routes("cashier", "Order", &ResourceOptions::new()).unwrap();
1058        assert!(route.base_url.starts_with("/addons/cashier/"));
1059        for entry in &route.entries {
1060            assert!(entry.url.starts_with("/addons/cashier/Order"));
1061        }
1062    }
1063
1064    #[test]
1065    fn test_r5_addon_prefix_injection_nested() {
1066        // 嵌套资源也注入 addon 前缀
1067        let route =
1068            build_resource_routes("operate", "blog.comment", &ResourceOptions::new()).unwrap();
1069        assert!(route.base_url.starts_with("/addons/operate/"));
1070        for entry in &route.entries {
1071            assert!(entry.url.starts_with("/addons/operate/blog/"));
1072        }
1073    }
1074}