ultimo 0.5.0

Modern Rust web framework with automatic TypeScript client generation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
//! Fast HTTP router with path parameter support
//!
//! Implements efficient path-based routing with support for:
//! - Static paths (/users)
//! - Path parameters (/users/:id)
//! - Multiple parameters (/users/:userId/posts/:postId)
//! - HTTP method matching

use std::collections::HashMap;

/// HTTP method enum
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Method {
    GET,
    POST,
    PUT,
    DELETE,
    PATCH,
    HEAD,
    OPTIONS,
}

impl Method {
    /// Parse method from hyper Method
    pub fn from_hyper(method: &hyper::Method) -> Option<Self> {
        match *method {
            hyper::Method::GET => Some(Method::GET),
            hyper::Method::POST => Some(Method::POST),
            hyper::Method::PUT => Some(Method::PUT),
            hyper::Method::DELETE => Some(Method::DELETE),
            hyper::Method::PATCH => Some(Method::PATCH),
            hyper::Method::HEAD => Some(Method::HEAD),
            hyper::Method::OPTIONS => Some(Method::OPTIONS),
            _ => None,
        }
    }
}

/// Path parameter map
pub type Params = HashMap<String, String>;

/// A single route segment
#[derive(Debug, Clone, PartialEq)]
enum Segment {
    /// Static path segment
    Static(String),
    /// Dynamic parameter segment (`:name`)
    Param(String),
    /// Catch-all wildcard segment (`*name`) — must be the last segment;
    /// captures all remaining path segments joined by `/`.
    Wildcard(String),
}

/// Route pattern for matching
#[derive(Debug, Clone)]
pub struct Route {
    segments: Vec<Segment>,
    raw_path: String,
}

impl Route {
    /// Create a new route from a path pattern
    pub fn new(path: &str) -> Self {
        let segments = Self::parse_path(path);
        Self {
            segments,
            raw_path: path.to_string(),
        }
    }

    /// Parse a path into segments
    fn parse_path(path: &str) -> Vec<Segment> {
        path.split('/')
            .filter(|s| !s.is_empty())
            .map(|segment| {
                if let Some(stripped) = segment.strip_prefix('*') {
                    Segment::Wildcard(stripped.to_string())
                } else if let Some(stripped) = segment.strip_prefix(':') {
                    Segment::Param(stripped.to_string())
                } else {
                    Segment::Static(segment.to_string())
                }
            })
            .collect()
    }

    /// Match this route against an incoming path
    pub fn matches(&self, path: &str) -> Option<Params> {
        let path_segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();

        // Check whether the last segment is a wildcard
        let has_wildcard = matches!(self.segments.last(), Some(Segment::Wildcard(_)));

        if has_wildcard {
            let prefix = &self.segments[..self.segments.len() - 1];
            // Wildcard requires at least one trailing segment beyond the prefix
            if path_segs.len() <= prefix.len() {
                return None;
            }
            let mut params = HashMap::new();
            for (route_seg, path_seg) in prefix.iter().zip(path_segs.iter()) {
                match route_seg {
                    Segment::Static(expected) => {
                        if expected.as_str() != *path_seg {
                            return None;
                        }
                    }
                    Segment::Param(name) => {
                        params.insert(name.clone(), (*path_seg).to_string());
                    }
                    Segment::Wildcard(_) => unreachable!("wildcard must be the last segment"),
                }
            }
            // Capture all remaining segments joined by '/'
            if let Some(Segment::Wildcard(name)) = self.segments.last() {
                let rest = path_segs[prefix.len()..].join("/");
                params.insert(name.clone(), rest);
            }
            return Some(params);
        }

        // Original logic: exact segment count required
        if path_segs.len() != self.segments.len() {
            return None;
        }
        let mut params = HashMap::new();
        for (route_seg, path_seg) in self.segments.iter().zip(path_segs.iter()) {
            match route_seg {
                Segment::Static(expected) => {
                    if expected.as_str() != *path_seg {
                        return None;
                    }
                }
                Segment::Param(name) => {
                    params.insert(name.clone(), (*path_seg).to_string());
                }
                Segment::Wildcard(_) => unreachable!("wildcard only at end"),
            }
        }
        Some(params)
    }

    /// Get the raw path pattern
    pub fn path(&self) -> &str {
        &self.raw_path
    }

    /// Route specificity: the number of static segments. Higher is more
    /// specific, so a fully-static route outranks one with parameters.
    fn specificity(&self) -> usize {
        self.segments
            .iter()
            .filter(|s| matches!(s, Segment::Static(_)))
            .count()
    }

    /// The normalized lookup key for a fully-static route (segments joined by
    /// `/`), or `None` if the route has any parameter. Matches `normalize_path`.
    fn static_key(&self) -> Option<String> {
        let mut parts: Vec<&str> = Vec::with_capacity(self.segments.len());
        for seg in &self.segments {
            match seg {
                Segment::Static(s) => parts.push(s),
                Segment::Param(_) | Segment::Wildcard(_) => return None,
            }
        }
        Some(parts.join("/"))
    }
}

/// Normalize a request path to a static-route key: non-empty segments joined by
/// `/`. Trailing and duplicate slashes are ignored, matching `Route::matches`.
fn normalize_path(path: &str) -> String {
    path.split('/')
        .filter(|s| !s.is_empty())
        .collect::<Vec<_>>()
        .join("/")
}

/// Router entry combining method, route, and handler index
#[derive(Debug, Clone)]
pub struct RouterEntry {
    pub method: Method,
    pub route: Route,
    pub handler_id: usize,
}

/// Main router struct.
///
/// Lookup is split for speed: fully-static routes go in an O(1) hash index
/// keyed by `(method, normalized-path)`, and only parameterized routes are
/// scanned. Because a fully-static match is always the most specific possible
/// for a path, a hit in the static index wins outright — so the precedence
/// guarantee (static beats param, ties by registration order) is preserved
/// while avoiding an O(N) scan over every registered route.
#[derive(Debug)]
pub struct Router {
    /// All routes in registration order — for `routes()` / introspection.
    routes: Vec<RouterEntry>,
    /// O(1) exact lookup for fully-static routes. First registration wins.
    static_index: HashMap<(Method, String), usize>,
    /// Parameterized routes only, scanned when there's no static match.
    dynamic: Vec<RouterEntry>,
}

impl Router {
    /// Create a new empty router
    pub fn new() -> Self {
        Self {
            routes: Vec::new(),
            static_index: HashMap::new(),
            dynamic: Vec::new(),
        }
    }

    /// Add a route to the router
    pub fn add_route(&mut self, method: Method, path: &str, handler_id: usize) {
        let route = Route::new(path);
        let entry = RouterEntry {
            method,
            route: route.clone(),
            handler_id,
        };
        match route.static_key() {
            // First registration wins (preserves the prior tie-break semantics).
            Some(key) => {
                self.static_index.entry((method, key)).or_insert(handler_id);
            }
            None => self.dynamic.push(entry.clone()),
        }
        self.routes.push(entry);
    }

    /// Find the best-matching route for the given method and path.
    ///
    /// A fully-static match is the most specific possible for a path, so it wins
    /// outright (O(1) via the static index). Otherwise only parameterized routes
    /// are scanned; the most specific wins, ties broken by registration order.
    pub fn find_route(&self, method: Method, path: &str) -> Option<(usize, Params)> {
        // Fast path: exact static match.
        let key = normalize_path(path);
        if let Some(&handler_id) = self.static_index.get(&(method, key)) {
            return Some((handler_id, Params::new()));
        }
        // Slow path: scan only the parameterized routes.
        let mut best: Option<(usize, Params, usize)> = None;
        for entry in &self.dynamic {
            if entry.method == method {
                if let Some(params) = entry.route.matches(path) {
                    let spec = entry.route.specificity();
                    let better = match &best {
                        Some((_, _, best_spec)) => spec > *best_spec,
                        None => true,
                    };
                    if better {
                        best = Some((entry.handler_id, params, spec));
                    }
                }
            }
        }
        best.map(|(id, params, _)| (id, params))
    }

    /// Get all registered routes (useful for debugging)
    pub fn routes(&self) -> &[RouterEntry] {
        &self.routes
    }
}

impl Default for Router {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_static_route() {
        let route = Route::new("/users");
        assert!(route.matches("/users").is_some());
        assert!(route.matches("/posts").is_none());
        assert!(route.matches("/users/123").is_none());
    }

    #[test]
    fn test_single_param() {
        let route = Route::new("/users/:id");
        let params = route.matches("/users/123");
        assert!(params.is_some());
        let params = params.unwrap();
        assert_eq!(params.get("id"), Some(&"123".to_string()));
    }

    #[test]
    fn test_multiple_params() {
        let route = Route::new("/users/:userId/posts/:postId");
        let params = route.matches("/users/42/posts/100");
        assert!(params.is_some());
        let params = params.unwrap();
        assert_eq!(params.get("userId"), Some(&"42".to_string()));
        assert_eq!(params.get("postId"), Some(&"100".to_string()));
    }

    #[test]
    fn test_no_match_different_length() {
        let route = Route::new("/users/:id");
        assert!(route.matches("/users").is_none());
        assert!(route.matches("/users/123/posts").is_none());
    }

    #[test]
    fn test_router_add_and_find() {
        let mut router = Router::new();
        router.add_route(Method::GET, "/users", 0);
        router.add_route(Method::GET, "/users/:id", 1);
        router.add_route(Method::POST, "/users", 2);

        // Test exact match
        let result = router.find_route(Method::GET, "/users");
        assert!(result.is_some());
        let (handler_id, params) = result.unwrap();
        assert_eq!(handler_id, 0);
        assert!(params.is_empty());

        // Test param match
        let result = router.find_route(Method::GET, "/users/123");
        assert!(result.is_some());
        let (handler_id, params) = result.unwrap();
        assert_eq!(handler_id, 1);
        assert_eq!(params.get("id"), Some(&"123".to_string()));

        // Test method mismatch
        let result = router.find_route(Method::PUT, "/users");
        assert!(result.is_none());
    }

    #[test]
    fn test_root_path() {
        let route = Route::new("/");
        assert!(route.matches("/").is_some());
        assert!(route.matches("/users").is_none());
    }

    #[test]
    fn test_mixed_static_and_params() {
        let route = Route::new("/api/v1/users/:id/profile");
        let params = route.matches("/api/v1/users/123/profile");
        assert!(params.is_some());
        let params = params.unwrap();
        assert_eq!(params.get("id"), Some(&"123".to_string()));
        assert_eq!(params.len(), 1);
    }

    #[test]
    fn static_route_beats_param_regardless_of_order() {
        // Param registered FIRST, static SECOND — static must still win.
        let mut r = Router::new();
        r.add_route(Method::GET, "/users/:id", 1);
        r.add_route(Method::GET, "/users/me", 2);
        let (id, _) = r.find_route(Method::GET, "/users/me").unwrap();
        assert_eq!(id, 2, "static /users/me should win over /users/:id");
        // and the param route still matches other values
        let (id, params) = r.find_route(Method::GET, "/users/42").unwrap();
        assert_eq!(id, 1);
        assert_eq!(params.get("id"), Some(&"42".to_string()));
    }

    #[test]
    fn trailing_slash_is_ignored() {
        let mut r = Router::new();
        r.add_route(Method::GET, "/users", 1);
        assert!(r.find_route(Method::GET, "/users/").is_some());
        assert!(r.find_route(Method::GET, "/users").is_some());
    }

    #[test]
    fn no_match_returns_none() {
        let mut r = Router::new();
        r.add_route(Method::GET, "/users/me", 1);
        assert!(r.find_route(Method::GET, "/posts").is_none());
        assert!(r.find_route(Method::POST, "/users/me").is_none());
    }

    #[test]
    fn many_static_routes_resolve_correctly() {
        // The static index must return the right handler regardless of table size.
        let mut r = Router::new();
        for i in 0..500 {
            r.add_route(Method::GET, &format!("/route/{i}"), i);
        }
        let (id, params) = r.find_route(Method::GET, "/route/250").unwrap();
        assert_eq!(id, 250);
        assert!(params.is_empty());
        assert!(r.find_route(Method::GET, "/route/999").is_none());
    }

    #[test]
    fn duplicate_static_route_keeps_first_registration() {
        // Two registrations of the same static path: the first wins.
        let mut r = Router::new();
        r.add_route(Method::GET, "/dup", 1);
        r.add_route(Method::GET, "/dup", 2);
        let (id, _) = r.find_route(Method::GET, "/dup").unwrap();
        assert_eq!(id, 1);
    }

    #[test]
    fn root_path_uses_static_index() {
        let mut r = Router::new();
        r.add_route(Method::GET, "/", 7);
        let (id, _) = r.find_route(Method::GET, "/").unwrap();
        assert_eq!(id, 7);
    }

    #[test]
    fn method_is_part_of_the_static_key() {
        let mut r = Router::new();
        r.add_route(Method::GET, "/x", 1);
        r.add_route(Method::POST, "/x", 2);
        assert_eq!(r.find_route(Method::GET, "/x").unwrap().0, 1);
        assert_eq!(r.find_route(Method::POST, "/x").unwrap().0, 2);
    }

    // --- Wildcard segment tests ---

    #[test]
    fn wildcard_matches_single_segment() {
        let route = Route::new("/assets/*path");
        let params = route.matches("/assets/style.css").unwrap();
        assert_eq!(params["path"], "style.css");
    }

    #[test]
    fn wildcard_matches_nested_path() {
        let route = Route::new("/assets/*path");
        let params = route.matches("/assets/css/theme/main.css").unwrap();
        assert_eq!(params["path"], "css/theme/main.css");
    }

    #[test]
    fn wildcard_requires_prefix_match() {
        let route = Route::new("/assets/*path");
        assert!(route.matches("/other/style.css").is_none());
    }

    #[test]
    fn wildcard_does_not_match_prefix_only() {
        // /assets alone has no trailing segment — wildcard requires at least one
        let route = Route::new("/assets/*path");
        assert!(route.matches("/assets").is_none());
    }

    #[test]
    fn wildcard_does_not_produce_static_key() {
        // Routes with wildcards must go in the dynamic (slow-path) scan
        let route = Route::new("/assets/*path");
        assert!(route.static_key().is_none());
    }

    #[test]
    fn wildcard_specificity_equals_static_prefix_count() {
        let route = Route::new("/assets/public/*path");
        // "assets" and "public" are static segments; wildcard counts as 0
        assert_eq!(route.specificity(), 2);
    }

    #[test]
    fn wildcard_route_is_found_via_router() {
        let mut r = Router::new();
        r.add_route(Method::GET, "/static/*path", 42);
        let (id, params) = r.find_route(Method::GET, "/static/js/app.js").unwrap();
        assert_eq!(id, 42);
        assert_eq!(params["path"], "js/app.js");
    }
}