Skip to main content

apcore_toolkit/
http_verb_map.rs

1// HTTP verb semantic mapping utilities.
2//
3// Provides the canonical mapping from HTTP methods to semantic verbs used
4// by scanner implementations when generating user-facing command aliases.
5// All functions are pure and infallible.
6
7use regex::Regex;
8use std::collections::{HashMap, HashSet};
9use std::sync::LazyLock;
10
11/// Canonical HTTP method to semantic verb mapping.
12///
13/// Keys are uppercase HTTP methods. `GET_ID` is a synthetic key used when
14/// GET routes have path parameters (single-resource access). Values are
15/// lowercase semantic verbs used by CLI and MCP surfaces. The mapping is
16/// considered immutable by convention.
17pub static SCANNER_VERB_MAP: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
18    let mut m = HashMap::new();
19    m.insert("GET", "list");
20    m.insert("GET_ID", "get");
21    m.insert("POST", "create");
22    m.insert("PUT", "update");
23    m.insert("PATCH", "patch");
24    m.insert("DELETE", "delete");
25    m.insert("HEAD", "head");
26    m.insert("OPTIONS", "options");
27    m
28});
29
30/// Regex covering path parameter syntax across major frameworks:
31///   FastAPI / Django / OpenAPI: `{param}`
32///   Express / NestJS / Gin / Axum: `:param`
33static PATH_PARAM_RE: LazyLock<Regex> =
34    LazyLock::new(|| Regex::new(r"\{[^}]+\}|:[a-zA-Z_]\w*").expect("valid regex"));
35
36/// Anchored variant for whole-segment match testing.
37static PATH_PARAM_FULL_RE: LazyLock<Regex> =
38    LazyLock::new(|| Regex::new(r"^(?:\{[^}]+\}|:[a-zA-Z_]\w*)$").expect("valid regex"));
39
40/// Named-capture variant: extracts the parameter name without punctuation.
41/// Mirrors Python's `_PATH_PARAM_NAMED_RE`.
42static PATH_PARAM_NAMED_RE: LazyLock<Regex> = LazyLock::new(|| {
43    Regex::new(r"\{(?P<brace>[^}]+)\}|:(?P<colon>[a-zA-Z_]\w*)").expect("valid regex")
44});
45
46/// Check if a URL path contains path parameter placeholders.
47///
48/// Detects both brace-style (`{param}`) and colon-style (`:param`) parameters,
49/// covering all major web frameworks.
50///
51/// # Examples
52///
53/// ```
54/// use apcore_toolkit::http_verb_map::has_path_params;
55///
56/// assert_eq!(has_path_params("/tasks"), false);
57/// assert_eq!(has_path_params("/tasks/{id}"), true);
58/// assert_eq!(has_path_params("/users/:userId"), true);
59/// ```
60pub fn has_path_params(path: &str) -> bool {
61    PATH_PARAM_RE.is_match(path)
62}
63
64/// Map an HTTP method to its semantic verb.
65///
66/// GET is contextual: collection routes (no path params) map to `"list"`,
67/// single-resource routes (with path params) map to `"get"`. All other
68/// methods have a static mapping. Unknown methods fall through to
69/// the lowercase form of the input.
70///
71/// # Arguments
72///
73/// * `method` - HTTP method string (case-insensitive).
74/// * `path_has_params` - True if the corresponding route has path parameters.
75///
76/// # Examples
77///
78/// ```
79/// use apcore_toolkit::http_verb_map::resolve_http_verb;
80///
81/// assert_eq!(resolve_http_verb("POST", false), "create");
82/// assert_eq!(resolve_http_verb("GET", false), "list");
83/// assert_eq!(resolve_http_verb("GET", true), "get");
84/// ```
85pub fn resolve_http_verb(method: &str, path_has_params: bool) -> String {
86    let method_upper = method.to_uppercase();
87    if method_upper == "GET" {
88        let key = if path_has_params { "GET_ID" } else { "GET" };
89        return SCANNER_VERB_MAP.get(key).copied().unwrap_or("").to_string();
90    }
91    SCANNER_VERB_MAP
92        .get(method_upper.as_str())
93        .copied()
94        .map(|s| s.to_string())
95        .unwrap_or_else(|| method.to_lowercase())
96}
97
98/// Generate a dot-separated suggested alias from HTTP route info.
99///
100/// The alias is built from non-parameter path segments joined with the
101/// resolved semantic verb. The output uses snake_case preserved from the
102/// path; surface adapters apply their own naming conventions (e.g., CLI
103/// converts underscores to hyphens).
104///
105/// The GET-vs-list disambiguation checks whether the LAST path segment
106/// is a path parameter (single-resource access) rather than whether the
107/// path contains any parameters anywhere. This correctly treats nested
108/// collection endpoints like `/orgs/{org_id}/members` as `"list"`.
109///
110/// # Examples
111///
112/// ```
113/// use apcore_toolkit::http_verb_map::generate_suggested_alias;
114///
115/// assert_eq!(
116///     generate_suggested_alias("/tasks/user_data", "POST"),
117///     "tasks.user_data.create"
118/// );
119/// assert_eq!(
120///     generate_suggested_alias("/tasks/user_data", "GET"),
121///     "tasks.user_data.list"
122/// );
123/// assert_eq!(
124///     generate_suggested_alias("/tasks/user_data/{id}", "GET"),
125///     "tasks.user_data.get"
126/// );
127/// assert_eq!(
128///     generate_suggested_alias("/orgs/{org_id}/members", "GET"),
129///     "orgs.members.list"
130/// );
131/// ```
132/// Return the set of parameter names declared in a URL path.
133///
134/// Recognises both brace-style (`/users/{id}`) and colon-style
135/// (`/users/:id`) placeholders. Returned names have the surrounding
136/// punctuation stripped.
137///
138/// # Examples
139///
140/// ```
141/// use apcore_toolkit::http_verb_map::extract_path_param_names;
142/// use std::collections::HashSet;
143///
144/// let names = extract_path_param_names("/orgs/{org_id}/members/:m");
145/// assert!(names.contains("org_id"));
146/// assert!(names.contains("m"));
147/// ```
148pub fn extract_path_param_names(path: &str) -> HashSet<String> {
149    let mut names: HashSet<String> = HashSet::new();
150    for caps in PATH_PARAM_NAMED_RE.captures_iter(path) {
151        if let Some(m) = caps.name("brace").or_else(|| caps.name("colon")) {
152            names.insert(m.as_str().to_string());
153        }
154    }
155    names
156}
157
158/// Substitute `{name}` and `:name` placeholders with `values[name]`.
159///
160/// Keys in `values` that do not appear as placeholders are ignored. A
161/// placeholder whose name is absent from `values` is left unchanged
162/// (callers can detect the leftover via [`extract_path_param_names`]).
163///
164/// # Examples
165///
166/// ```
167/// use apcore_toolkit::http_verb_map::substitute_path_params;
168/// use std::collections::HashMap;
169///
170/// let mut values: HashMap<&str, String> = HashMap::new();
171/// values.insert("id", "42".to_string());
172/// assert_eq!(substitute_path_params("/users/{id}", &values), "/users/42");
173/// ```
174pub fn substitute_path_params<V: AsRef<str>>(path: &str, values: &HashMap<&str, V>) -> String {
175    let mut result = String::with_capacity(path.len());
176    let mut last = 0usize;
177    for caps in PATH_PARAM_NAMED_RE.captures_iter(path) {
178        let whole = caps.get(0).expect("full match present");
179        result.push_str(&path[last..whole.start()]);
180        let name = caps
181            .name("brace")
182            .or_else(|| caps.name("colon"))
183            .map(|m| m.as_str());
184        match name.and_then(|n| values.get(n)) {
185            Some(v) => result.push_str(v.as_ref()),
186            None => result.push_str(whole.as_str()),
187        }
188        last = whole.end();
189    }
190    result.push_str(&path[last..]);
191    result
192}
193
194pub fn generate_suggested_alias(path: &str, method: &str) -> String {
195    let trimmed = path.trim_matches('/');
196    let raw_segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
197    let segments: Vec<&str> = raw_segments
198        .iter()
199        .copied()
200        .filter(|s| !PATH_PARAM_FULL_RE.is_match(s))
201        .collect();
202    let is_single_resource = raw_segments
203        .last()
204        .map(|s| PATH_PARAM_FULL_RE.is_match(s))
205        .unwrap_or(false);
206    let verb = resolve_http_verb(method, is_single_resource);
207    let mut parts: Vec<String> = segments.iter().map(|s| s.to_string()).collect();
208    parts.push(verb);
209    parts.join(".")
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    // ---- has_path_params ----
217
218    #[test]
219    fn test_has_path_params_empty_string() {
220        assert!(!has_path_params(""));
221    }
222
223    #[test]
224    fn test_has_path_params_root_path() {
225        assert!(!has_path_params("/"));
226    }
227
228    #[test]
229    fn test_has_path_params_static_path() {
230        assert!(!has_path_params("/tasks"));
231    }
232
233    #[test]
234    fn test_has_path_params_brace_style() {
235        assert!(has_path_params("/tasks/{id}"));
236    }
237
238    #[test]
239    fn test_has_path_params_colon_style() {
240        assert!(has_path_params("/tasks/:id"));
241    }
242
243    #[test]
244    fn test_has_path_params_mixed_styles() {
245        assert!(has_path_params("/{id}/:name"));
246    }
247
248    #[test]
249    fn test_has_path_params_multi_segment_static() {
250        assert!(!has_path_params("/a/b/c"));
251    }
252
253    #[test]
254    fn test_has_path_params_empty_brace() {
255        assert!(!has_path_params("/tasks/{}"));
256    }
257
258    // ---- resolve_http_verb ----
259
260    #[test]
261    fn test_resolve_http_verb_get_collection() {
262        assert_eq!(resolve_http_verb("GET", false), "list");
263    }
264
265    #[test]
266    fn test_resolve_http_verb_get_single() {
267        assert_eq!(resolve_http_verb("GET", true), "get");
268    }
269
270    #[test]
271    fn test_resolve_http_verb_get_case_insensitive() {
272        assert_eq!(resolve_http_verb("get", false), "list");
273    }
274
275    #[test]
276    fn test_resolve_http_verb_post_no_params() {
277        assert_eq!(resolve_http_verb("POST", false), "create");
278    }
279
280    #[test]
281    fn test_resolve_http_verb_post_with_params() {
282        assert_eq!(resolve_http_verb("POST", true), "create");
283    }
284
285    #[test]
286    fn test_resolve_http_verb_put() {
287        assert_eq!(resolve_http_verb("PUT", true), "update");
288    }
289
290    #[test]
291    fn test_resolve_http_verb_patch() {
292        assert_eq!(resolve_http_verb("PATCH", true), "patch");
293    }
294
295    #[test]
296    fn test_resolve_http_verb_delete() {
297        assert_eq!(resolve_http_verb("DELETE", true), "delete");
298    }
299
300    #[test]
301    fn test_resolve_http_verb_head() {
302        assert_eq!(resolve_http_verb("HEAD", false), "head");
303    }
304
305    #[test]
306    fn test_resolve_http_verb_options() {
307        assert_eq!(resolve_http_verb("OPTIONS", false), "options");
308    }
309
310    #[test]
311    fn test_resolve_http_verb_unknown_method() {
312        assert_eq!(resolve_http_verb("PURGE", false), "purge");
313    }
314
315    #[test]
316    fn test_resolve_http_verb_empty_method() {
317        assert_eq!(resolve_http_verb("", false), "");
318    }
319
320    // ---- generate_suggested_alias ----
321
322    #[test]
323    fn test_generate_alias_post_collection() {
324        assert_eq!(
325            generate_suggested_alias("/tasks/user_data", "POST"),
326            "tasks.user_data.create"
327        );
328    }
329
330    #[test]
331    fn test_generate_alias_get_collection() {
332        assert_eq!(
333            generate_suggested_alias("/tasks/user_data", "GET"),
334            "tasks.user_data.list"
335        );
336    }
337
338    #[test]
339    fn test_generate_alias_get_single() {
340        assert_eq!(
341            generate_suggested_alias("/tasks/user_data/{id}", "GET"),
342            "tasks.user_data.get"
343        );
344    }
345
346    #[test]
347    fn test_generate_alias_put_single() {
348        assert_eq!(
349            generate_suggested_alias("/tasks/user_data/{id}", "PUT"),
350            "tasks.user_data.update"
351        );
352    }
353
354    #[test]
355    fn test_generate_alias_patch_single() {
356        assert_eq!(
357            generate_suggested_alias("/tasks/user_data/{id}", "PATCH"),
358            "tasks.user_data.patch"
359        );
360    }
361
362    #[test]
363    fn test_generate_alias_delete_single() {
364        assert_eq!(
365            generate_suggested_alias("/tasks/user_data/{id}", "DELETE"),
366            "tasks.user_data.delete"
367        );
368    }
369
370    #[test]
371    fn test_generate_alias_single_segment() {
372        assert_eq!(generate_suggested_alias("/health", "GET"), "health.list");
373    }
374
375    #[test]
376    fn test_generate_alias_root_path() {
377        assert_eq!(generate_suggested_alias("/", "GET"), "list");
378    }
379
380    #[test]
381    fn test_generate_alias_empty_path() {
382        assert_eq!(generate_suggested_alias("", "GET"), "list");
383    }
384
385    #[test]
386    fn test_generate_alias_colon_param() {
387        assert_eq!(
388            generate_suggested_alias("/users/:user_id", "GET"),
389            "users.get"
390        );
391    }
392
393    #[test]
394    fn test_generate_alias_version_prefix() {
395        assert_eq!(
396            generate_suggested_alias("/api/v2/users", "GET"),
397            "api.v2.users.list"
398        );
399    }
400
401    #[test]
402    fn test_generate_alias_nested_params_collection() {
403        assert_eq!(
404            generate_suggested_alias("/orgs/{org_id}/teams/{team_id}/members", "GET"),
405            "orgs.teams.members.list"
406        );
407    }
408
409    #[test]
410    fn test_generate_alias_double_slashes() {
411        assert_eq!(
412            generate_suggested_alias("//tasks//user_data//", "POST"),
413            "tasks.user_data.create"
414        );
415    }
416
417    #[test]
418    fn test_generate_alias_param_only_path() {
419        assert_eq!(generate_suggested_alias("/{id}", "GET"), "get");
420    }
421
422    // ---- extract_path_param_names ----
423
424    #[test]
425    fn test_extract_names_static_path_empty() {
426        assert!(extract_path_param_names("/tasks").is_empty());
427    }
428
429    #[test]
430    fn test_extract_names_brace_single() {
431        let names = extract_path_param_names("/users/{id}");
432        assert_eq!(names.len(), 1);
433        assert!(names.contains("id"));
434    }
435
436    #[test]
437    fn test_extract_names_colon_single() {
438        let names = extract_path_param_names("/users/:id");
439        assert_eq!(names.len(), 1);
440        assert!(names.contains("id"));
441    }
442
443    #[test]
444    fn test_extract_names_mixed_multiple() {
445        let names = extract_path_param_names("/orgs/{org_id}/members/:member_id");
446        assert_eq!(names.len(), 2);
447        assert!(names.contains("org_id"));
448        assert!(names.contains("member_id"));
449    }
450
451    #[test]
452    fn test_extract_names_deduplicates() {
453        let names = extract_path_param_names("/a/{id}/b/{id}");
454        assert_eq!(names.len(), 1);
455        assert!(names.contains("id"));
456    }
457
458    // ---- substitute_path_params ----
459
460    #[test]
461    fn test_substitute_brace_value() {
462        let mut values: HashMap<&str, String> = HashMap::new();
463        values.insert("id", "42".to_string());
464        assert_eq!(substitute_path_params("/users/{id}", &values), "/users/42");
465    }
466
467    #[test]
468    fn test_substitute_colon_value() {
469        let mut values: HashMap<&str, String> = HashMap::new();
470        values.insert("id", "abc".to_string());
471        assert_eq!(substitute_path_params("/users/:id", &values), "/users/abc");
472    }
473
474    #[test]
475    fn test_substitute_leaves_unknown_placeholder() {
476        let mut values: HashMap<&str, String> = HashMap::new();
477        values.insert("id", "1".to_string());
478        assert_eq!(
479            substitute_path_params("/users/{id}/{role}", &values),
480            "/users/1/{role}"
481        );
482    }
483
484    #[test]
485    fn test_substitute_ignores_extra_keys() {
486        let mut values: HashMap<&str, String> = HashMap::new();
487        values.insert("id", "1".to_string());
488        values.insert("extra", "x".to_string());
489        assert_eq!(substitute_path_params("/users/{id}", &values), "/users/1");
490    }
491
492    #[test]
493    fn test_substitute_no_placeholders() {
494        let values: HashMap<&str, String> = HashMap::new();
495        assert_eq!(substitute_path_params("/tasks", &values), "/tasks");
496    }
497
498    #[test]
499    fn test_substitute_multiple_mixed_styles() {
500        let mut values: HashMap<&str, String> = HashMap::new();
501        values.insert("org_id", "7".to_string());
502        values.insert("m", "me".to_string());
503        assert_eq!(
504            substitute_path_params("/orgs/{org_id}/members/:m", &values),
505            "/orgs/7/members/me"
506        );
507    }
508
509    // ---- SCANNER_VERB_MAP ----
510
511    #[test]
512    fn test_scanner_verb_map_contains_standard_methods() {
513        for k in &[
514            "GET", "GET_ID", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS",
515        ] {
516            assert!(SCANNER_VERB_MAP.contains_key(k), "missing key: {}", k);
517        }
518    }
519
520    #[test]
521    fn test_scanner_verb_map_values_lowercase() {
522        for v in SCANNER_VERB_MAP.values() {
523            assert_eq!(*v, &*v.to_lowercase());
524        }
525    }
526
527    // ---- Conformance fixture ----
528
529    #[test]
530    fn test_conformance_fixture() {
531        let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
532            .join("tests")
533            .join("fixtures")
534            .join("scanner_verb_map.json");
535
536        let content = std::fs::read_to_string(&fixture_path)
537            .unwrap_or_else(|e| panic!("failed to read fixture at {:?}: {}", fixture_path, e));
538
539        let cases: serde_json::Value =
540            serde_json::from_str(&content).expect("fixture must be valid JSON");
541
542        let array = cases.as_array().expect("fixture must be a JSON array");
543        assert!(!array.is_empty(), "fixture must contain at least one case");
544
545        for case in array {
546            let path = case["path"].as_str().unwrap();
547            let method = case["method"].as_str().unwrap();
548            let expected = case["expected_alias"].as_str().unwrap();
549
550            let result = generate_suggested_alias(path, method);
551            assert_eq!(
552                result, expected,
553                "fixture mismatch for {} {}: got {}, expected {}",
554                method, path, result, expected
555            );
556        }
557    }
558}