spin_http_routes/
lib.rs

1//! Route matching for the HTTP trigger.
2
3#![deny(missing_docs)]
4
5use anyhow::{anyhow, Result};
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use std::{borrow::Cow, collections::HashMap, fmt};
9
10/// The prefix for well-known routes.
11pub const WELL_KNOWN_PREFIX: &str = "/.well-known/spin/";
12
13/// Router for the HTTP trigger.
14#[derive(Clone, Debug)]
15pub struct Router {
16    /// Resolves paths to routing information - specifically component IDs
17    /// but also recording about the original route.
18    router: std::sync::Arc<routefinder::Router<RouteHandler>>,
19}
20
21/// What a route maps to
22#[derive(Clone, Debug)]
23struct RouteHandler {
24    /// The handler identifier (typically component ID) that the route maps to.
25    lookup_key: TriggerLookupKey,
26    /// The route, including any application base.
27    based_route: Cow<'static, str>,
28    /// The route, not including any application base.
29    raw_route: Cow<'static, str>,
30    /// The route, including any application base and capturing information about whether it has a trailing wildcard.
31    /// (This avoids re-parsing the route string.)
32    parsed_based_route: ParsedRoute,
33}
34
35/// An identifier that can be returned from a RouteMatch and used to look up the trigger
36/// that handles the route.
37#[derive(Clone, Debug, Hash, PartialEq, Eq)]
38pub enum TriggerLookupKey {
39    /// The route is handled by the specified Wasm component.
40    Component(String),
41    /// The route is handled directly within the specified trigger.
42    Trigger(String),
43}
44
45impl std::fmt::Display for TriggerLookupKey {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self {
48            Self::Component(id) => f.write_str(id),
49            Self::Trigger(id) => f.write_str(id),
50        }
51    }
52}
53
54/// A detected duplicate route.
55#[derive(Debug)] // Needed to call `expect_err` on `Router::build`
56pub struct DuplicateRoute {
57    /// The duplicated route pattern.
58    route: String,
59    /// The raw route that was duplicated.
60    pub replaced_id: String,
61    /// The component ID corresponding to the duplicated route.
62    pub effective_id: String,
63}
64
65impl Router {
66    /// Builds a router based on application configuration.
67    ///
68    /// `duplicate_routes` is an optional mutable reference to a vector of `DuplicateRoute`
69    /// that will be populated with any duplicate routes found during the build process.
70    pub fn build<'a>(
71        base: &str,
72        trigger_routes: impl IntoIterator<Item = (&'a TriggerLookupKey, &'a HttpTriggerRouteConfig)>,
73        mut duplicate_routes: Option<&mut Vec<DuplicateRoute>>,
74    ) -> Result<Self> {
75        // Some information we need to carry between stages of the builder.
76        struct RoutingEntry<'a> {
77            based_route: String,
78            raw_route: &'a str,
79            lookup_key: &'a TriggerLookupKey,
80        }
81
82        let mut routes: IndexMap<&str, RoutingEntry> = IndexMap::new();
83
84        // Filter out private endpoints and capture the routes.
85        let routes_iter = trigger_routes
86            .into_iter()
87            .filter_map(|(lookup_key, route)| {
88                match route {
89                    HttpTriggerRouteConfig::Route(raw_route) => {
90                        let based_route = sanitize_with_base(base, raw_route);
91                        Some(Ok(RoutingEntry { based_route, raw_route, lookup_key }))
92                    }
93                    HttpTriggerRouteConfig::Private(endpoint) => if endpoint.private {
94                        None
95                    } else {
96                        Some(Err(anyhow!("route must be a string pattern or '{{ private = true }}': '{lookup_key}' has {{ private = false }}")))
97                    }
98                }
99            });
100
101        // Remove duplicates.
102        for re in routes_iter {
103            let re = re?;
104            if let Some(replaced) = routes.insert(re.raw_route, re) {
105                if let Some(duplicate_routes) = &mut duplicate_routes {
106                    let effective_id = routes
107                        .get(replaced.raw_route)
108                        .unwrap() // Safe because we just inserted it
109                        .lookup_key
110                        .to_string();
111                    duplicate_routes.push(DuplicateRoute {
112                        route: replaced.based_route,
113                        replaced_id: replaced.lookup_key.to_string(),
114                        effective_id,
115                    });
116                }
117            }
118        }
119
120        // Build a `routefinder` from the remaining routes.
121
122        let mut rf = routefinder::Router::new();
123
124        for re in routes.into_values() {
125            let (rfroute, parsed) = Self::parse_route(&re.based_route).map_err(|e| {
126                anyhow!(
127                    "Error parsing route {} associated with component {}: {e}",
128                    re.based_route,
129                    re.lookup_key,
130                )
131            })?;
132
133            let handler = RouteHandler {
134                lookup_key: re.lookup_key.clone(),
135                based_route: re.based_route.into(),
136                raw_route: re.raw_route.to_string().into(),
137                parsed_based_route: parsed,
138            };
139
140            rf.add(rfroute, handler).map_err(|e| anyhow!("{e}"))?;
141        }
142
143        let router = Self {
144            router: std::sync::Arc::new(rf),
145        };
146
147        Ok(router)
148    }
149
150    fn parse_route(based_route: &str) -> Result<(routefinder::RouteSpec, ParsedRoute), String> {
151        if let Some(wild_suffixed) = based_route.strip_suffix("/...") {
152            let rs = format!("{wild_suffixed}/*").try_into()?;
153            let parsed = ParsedRoute::trailing_wildcard(wild_suffixed);
154            Ok((rs, parsed))
155        } else if let Some(wild_suffixed) = based_route.strip_suffix("/*") {
156            let rs = based_route.try_into()?;
157            let parsed = ParsedRoute::trailing_wildcard(wild_suffixed);
158            Ok((rs, parsed))
159        } else {
160            let rs = based_route.try_into()?;
161            let parsed = ParsedRoute::exact(based_route);
162            Ok((rs, parsed))
163        }
164    }
165
166    /// Returns the constructed routes.
167    pub fn routes(
168        &self,
169    ) -> impl Iterator<Item = (&(impl fmt::Display + fmt::Debug), &TriggerLookupKey)> {
170        self.router
171            .iter()
172            .map(|(_spec, handler)| (&handler.parsed_based_route, &handler.lookup_key))
173    }
174
175    /// true if one or more routes is under the reserved `/.well-known/spin/*`
176    /// prefix; otherwise false.
177    pub fn contains_reserved_route(&self) -> bool {
178        self.router
179            .iter()
180            .any(|(_spec, handler)| handler.based_route.starts_with(crate::WELL_KNOWN_PREFIX))
181    }
182
183    /// This returns the component ID that should handle the given path, or an error
184    /// if no component matches.
185    ///
186    /// If multiple components could potentially handle the same request based on their
187    /// defined routes, components with matching exact routes take precedence followed
188    /// by matching wildcard patterns with the longest matching prefix.
189    pub fn route<'path, 'router: 'path>(
190        &'router self,
191        path: &'path str,
192    ) -> Result<RouteMatch<'router, 'path>> {
193        let best_match = self
194            .router
195            .best_match(path)
196            .ok_or_else(|| anyhow!("Cannot match route for path {path}"))?;
197
198        let route_handler = best_match.handler();
199        let captures = best_match.captures();
200
201        Ok(RouteMatch {
202            inner: RouteMatchKind::Real {
203                route_handler,
204                captures,
205                path,
206            },
207        })
208    }
209}
210
211impl DuplicateRoute {
212    /// The duplicated route pattern.
213    pub fn route(&self) -> &str {
214        if self.route.is_empty() {
215            "/"
216        } else {
217            &self.route
218        }
219    }
220}
221
222#[derive(Clone, Debug)]
223enum ParsedRoute {
224    Exact(String),
225    TrailingWildcard(String),
226}
227
228impl ParsedRoute {
229    fn exact(route: impl Into<String>) -> Self {
230        Self::Exact(route.into())
231    }
232
233    fn trailing_wildcard(route: impl Into<String>) -> Self {
234        Self::TrailingWildcard(route.into())
235    }
236}
237
238impl fmt::Display for ParsedRoute {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        match &self {
241            ParsedRoute::Exact(path) => write!(f, "{path}"),
242            ParsedRoute::TrailingWildcard(pattern) => write!(f, "{pattern} (wildcard)"),
243        }
244    }
245}
246
247/// A routing match for a URL.
248pub struct RouteMatch<'router, 'path> {
249    inner: RouteMatchKind<'router, 'path>,
250}
251
252impl RouteMatch<'_, '_> {
253    /// A synthetic match as if the given path was matched against the wildcard route.
254    /// Used in service chaining; always directs to a component.
255    pub fn synthetic(component_id: String, path: String) -> Self {
256        Self {
257            inner: RouteMatchKind::Synthetic {
258                route_handler: RouteHandler {
259                    lookup_key: TriggerLookupKey::Component(component_id),
260                    based_route: "/...".into(),
261                    raw_route: "/...".into(),
262                    parsed_based_route: ParsedRoute::TrailingWildcard(String::new()),
263                },
264                trailing_wildcard: path,
265            },
266        }
267    }
268
269    /// An identifier for looking up the matched handler.
270    pub fn lookup_key(&self) -> &TriggerLookupKey {
271        &self.inner.route_handler().lookup_key
272    }
273
274    /// The matched route, as originally written in the manifest, combined with the base.
275    pub fn based_route(&self) -> &str {
276        &self.inner.route_handler().based_route
277    }
278
279    /// The matched route, excluding any trailing wildcard, combined with the base.
280    pub fn based_route_or_prefix(&self) -> &str {
281        self.inner
282            .route_handler()
283            .based_route
284            .strip_suffix("/...")
285            .unwrap_or(&self.inner.route_handler().based_route)
286    }
287
288    /// The matched route, as originally written in the manifest.
289    pub fn raw_route(&self) -> &str {
290        &self.inner.route_handler().raw_route
291    }
292
293    /// The matched route, excluding any trailing wildcard.
294    pub fn raw_route_or_prefix(&self) -> &str {
295        self.inner
296            .route_handler()
297            .raw_route
298            .strip_suffix("/...")
299            .unwrap_or(&self.inner.route_handler().raw_route)
300    }
301
302    /// The named wildcards captured from the path, if any
303    pub fn named_wildcards(&self) -> HashMap<&str, &str> {
304        self.inner.named_wildcards()
305    }
306
307    /// The trailing wildcard part of the path, if any
308    pub fn trailing_wildcard(&self) -> Cow<'_, str> {
309        self.inner.trailing_wildcard()
310    }
311}
312
313/// The kind of route match that was made.
314///
315/// Can either be real based on the routefinder or synthetic based on hardcoded results.
316enum RouteMatchKind<'router, 'path> {
317    /// A synthetic match as if the given path was matched against the wildcard route.
318    Synthetic {
319        /// The route handler that matched the path.
320        route_handler: RouteHandler,
321        /// The trailing wildcard part of the path
322        trailing_wildcard: String,
323    },
324    /// A real match.
325    Real {
326        /// The route handler that matched the path.
327        route_handler: &'router RouteHandler,
328        /// The best match for the path.
329        captures: routefinder::Captures<'router, 'path>,
330        /// The path that was matched.
331        path: &'path str,
332    },
333}
334
335impl RouteMatchKind<'_, '_> {
336    /// The route handler that matched the path.
337    fn route_handler(&self) -> &RouteHandler {
338        match self {
339            RouteMatchKind::Synthetic { route_handler, .. } => route_handler,
340            RouteMatchKind::Real { route_handler, .. } => route_handler,
341        }
342    }
343
344    /// The named wildcards captured from the path, if any
345    pub fn named_wildcards(&self) -> HashMap<&str, &str> {
346        let Self::Real { captures, .. } = &self else {
347            return HashMap::new();
348        };
349        captures.iter().collect()
350    }
351
352    /// The trailing wildcard part of the path, if any
353    pub fn trailing_wildcard(&self) -> Cow<'_, str> {
354        let (captures, path) = match self {
355            // If we have a synthetic match, we already have the trailing wildcard.
356            Self::Synthetic {
357                trailing_wildcard, ..
358            } => return trailing_wildcard.into(),
359            Self::Real { captures, path, .. } => (captures, path),
360        };
361
362        captures
363            .wildcard()
364            .map(|s|
365            // Backward compatibility considerations - Spin has traditionally
366            // captured trailing slashes, but routefinder does not.
367            match (s.is_empty(), path.ends_with('/')) {
368                // route: /foo/..., path: /foo
369                (true, false) => s.into(),
370                // route: /foo/..., path: /foo/
371                (true, true) => "/".into(),
372                // route: /foo/..., path: /foo/bar
373                (false, false) => format!("/{s}").into(),
374                // route: /foo/..., path: /foo/bar/
375                (false, true) => format!("/{s}/").into(),
376            })
377            .unwrap_or_default()
378    }
379}
380
381/// Sanitizes the base and path and return a formed path.
382fn sanitize_with_base<S: Into<String>>(base: S, path: S) -> String {
383    let path = absolutize(path);
384
385    format!("{}{}", sanitize(base.into()), sanitize(path))
386}
387
388fn absolutize<S: Into<String>>(s: S) -> String {
389    let s = s.into();
390    if s.starts_with('/') {
391        s
392    } else {
393        format!("/{s}")
394    }
395}
396
397/// Strips the trailing slash from a string.
398fn sanitize<S: Into<String>>(s: S) -> String {
399    let s = s.into();
400    // TODO
401    // This only strips a single trailing slash.
402    // Should we attempt to strip all trailing slashes?
403    match s.strip_suffix('/') {
404        Some(s) => s.into(),
405        None => s,
406    }
407}
408
409/// An HTTP trigger route
410#[derive(Clone, Debug, Deserialize, Serialize)]
411#[serde(untagged)]
412pub enum HttpTriggerRouteConfig {
413    /// A route that is routable.
414    Route(String),
415    /// A route that is not routable, but indicates a private endpoint.
416    Private(HttpPrivateEndpoint),
417}
418
419/// Indicates that a trigger is a private endpoint (not routable).
420#[derive(Clone, Debug, Default, Deserialize, Serialize)]
421#[serde(deny_unknown_fields)]
422pub struct HttpPrivateEndpoint {
423    /// Whether the private endpoint is private. This must be true.
424    pub private: bool,
425}
426
427impl Default for HttpTriggerRouteConfig {
428    fn default() -> Self {
429        Self::Route(Default::default())
430    }
431}
432
433impl<T: Into<String>> From<T> for HttpTriggerRouteConfig {
434    fn from(value: T) -> Self {
435        Self::Route(value.into())
436    }
437}
438
439#[cfg(test)]
440mod route_tests {
441    use super::*;
442
443    fn component_key(value: &str) -> TriggerLookupKey {
444        TriggerLookupKey::Component(value.to_string())
445    }
446
447    fn trigger_key(value: &str) -> TriggerLookupKey {
448        TriggerLookupKey::Trigger(value.to_string())
449    }
450
451    impl TriggerLookupKey {
452        fn component_id(&self) -> &str {
453            match self {
454                TriggerLookupKey::Component(id) => id,
455                TriggerLookupKey::Trigger(_) => {
456                    panic!("expected component ref but was trigger ref")
457                }
458            }
459        }
460    }
461
462    /// Produces a router using component routes only
463    fn component_router<'a>(
464        base: &str,
465        components: impl IntoIterator<Item = (&'a str, &'a str)>,
466        duplicate_routes: Option<&mut Vec<DuplicateRoute>>,
467    ) -> anyhow::Result<Router> {
468        let owned_routes = components
469            .into_iter()
470            .map(|(cid, path)| (component_key(cid), HttpTriggerRouteConfig::from(path)))
471            .collect::<Vec<_>>();
472        let routes = owned_routes.iter().map(|(k, v)| (k, v)); // Yes, I'm afraid this is necessary
473
474        Router::build(base, routes, duplicate_routes)
475    }
476
477    impl RouteMatch<'_, '_> {
478        fn component_id(&self) -> &str {
479            self.lookup_key().component_id()
480        }
481    }
482
483    #[test]
484    fn test_router_exact() -> Result<()> {
485        let r = component_router("/", [("foo", "/foo"), ("foobar", "/foo/bar")], None)?;
486
487        assert_eq!(r.route("/foo")?.component_id(), "foo");
488        assert_eq!(r.route("/foo/bar")?.component_id(), "foobar");
489        Ok(())
490    }
491
492    #[test]
493    fn router_returns_trigger_or_component() -> Result<()> {
494        let r = Router::build(
495            "/",
496            [
497                (&component_key("compy"), &"/foo".into()),
498                (&trigger_key("triggy"), &"/foo/bar".into()),
499            ],
500            None,
501        )?;
502
503        assert!(
504            matches!(r.route("/foo")?.lookup_key(), TriggerLookupKey::Component(c) if c == "compy")
505        );
506        assert!(
507            matches!(r.route("/foo/bar")?.lookup_key(), TriggerLookupKey::Trigger(t) if t == "triggy")
508        );
509        Ok(())
510    }
511
512    #[test]
513    fn test_router_respects_base() -> Result<()> {
514        let r = component_router("/base", [("foo", "/foo"), ("foobar", "/foo/bar")], None)?;
515
516        assert_eq!(r.route("/base/foo")?.component_id(), "foo");
517        assert_eq!(r.route("/base/foo/bar")?.component_id(), "foobar");
518        Ok(())
519    }
520
521    #[test]
522    fn test_router_wildcard() -> Result<()> {
523        let r = component_router("/", [("all", "/...")], None)?;
524
525        assert_eq!(r.route("/foo/bar")?.component_id(), "all");
526        assert_eq!(r.route("/abc/")?.component_id(), "all");
527        assert_eq!(r.route("/")?.component_id(), "all");
528        assert_eq!(
529            r.route("/this/should/be/captured?abc=def")?.component_id(),
530            "all"
531        );
532        Ok(())
533    }
534
535    #[test]
536    fn wildcard_routes_use_custom_display() {
537        let routes = component_router("/", vec![("comp", "/whee/...")], None).unwrap();
538
539        let (route, rh) = routes.routes().next().unwrap();
540
541        assert_eq!("comp", rh.component_id());
542        assert_eq!("/whee (wildcard)", format!("{route}"));
543    }
544
545    #[test]
546    fn test_router_respects_longest_match() -> Result<()> {
547        let r = component_router(
548            "/",
549            [
550                ("one_wildcard", "/one/..."),
551                ("onetwo_wildcard", "/one/two/..."),
552                ("onetwothree_wildcard", "/one/two/three/..."),
553            ],
554            None,
555        )?;
556
557        assert_eq!(
558            r.route("/one/two/three/four")?.component_id(),
559            "onetwothree_wildcard"
560        );
561
562        // ...regardless of order
563        let r = component_router(
564            "/",
565            [
566                ("onetwothree_wildcard", "/one/two/three/..."),
567                ("onetwo_wildcard", "/one/two/..."),
568                ("one_wildcard", "/one/..."),
569            ],
570            None,
571        )?;
572
573        assert_eq!(
574            r.route("/one/two/three/four")?.component_id(),
575            "onetwothree_wildcard"
576        );
577        Ok(())
578    }
579
580    #[test]
581    fn test_router_exact_beats_wildcard() -> Result<()> {
582        let r = component_router("/", [("one_exact", "/one"), ("wildcard", "/...")], None)?;
583
584        assert_eq!(r.route("/one")?.component_id(), "one_exact");
585
586        Ok(())
587    }
588
589    #[test]
590    fn sensible_routes_are_reachable() {
591        let mut duplicates = Vec::new();
592        let routes = component_router(
593            "/",
594            [
595                ("/", "/"),
596                ("/foo", "/foo"),
597                ("/bar", "/bar"),
598                ("/whee/...", "/whee/..."),
599            ],
600            Some(&mut duplicates),
601        )
602        .unwrap();
603
604        assert_eq!(4, routes.routes().count());
605        assert_eq!(0, duplicates.len());
606    }
607
608    #[test]
609    fn order_of_reachable_routes_is_preserved() {
610        let routes = component_router(
611            "/",
612            [
613                ("comp-/", "/"),
614                ("comp-/foo", "/foo"),
615                ("comp-/bar", "/bar"),
616                ("comp-/whee/...", "/whee/..."),
617            ],
618            None,
619        )
620        .unwrap();
621
622        assert_eq!("comp-/", routes.routes().next().unwrap().1.component_id());
623        assert_eq!(
624            "comp-/foo",
625            routes.routes().nth(1).unwrap().1.component_id()
626        );
627        assert_eq!(
628            "comp-/bar",
629            routes.routes().nth(2).unwrap().1.component_id()
630        );
631        assert_eq!(
632            "comp-/whee/...",
633            routes.routes().nth(3).unwrap().1.component_id()
634        );
635    }
636
637    #[test]
638    fn duplicate_routes_are_unreachable() {
639        let mut duplicates = Vec::new();
640        let routes = component_router(
641            "/",
642            [
643                ("comp-/", "/"),
644                ("comp-first /foo", "/foo"),
645                ("comp-second /foo", "/foo"),
646                ("comp-/whee/...", "/whee/..."),
647            ],
648            Some(&mut duplicates),
649        )
650        .unwrap();
651
652        assert_eq!(3, routes.routes().count());
653        assert_eq!(1, duplicates.len());
654    }
655
656    #[test]
657    fn duplicate_routes_last_one_wins() {
658        let mut duplicates = Vec::new();
659        let routes = component_router(
660            "/",
661            [
662                ("comp-/", "/"),
663                ("comp-first /foo", "/foo"),
664                ("comp-second /foo", "/foo"),
665                ("comp-/whee/...", "/whee/..."),
666            ],
667            Some(&mut duplicates),
668        )
669        .unwrap();
670
671        assert_eq!(
672            "comp-second /foo",
673            routes.routes().nth(1).unwrap().1.component_id()
674        );
675        assert_eq!("comp-first /foo", duplicates[0].replaced_id);
676        assert_eq!("comp-second /foo", duplicates[0].effective_id);
677    }
678
679    #[test]
680    fn duplicate_routes_reporting_is_faithful() {
681        let mut duplicates = Vec::new();
682        let _ = component_router(
683            "/",
684            [
685                ("comp-first /", "/"),
686                ("comp-second /", "/"),
687                ("comp-first /foo", "/foo"),
688                ("comp-second /foo", "/foo"),
689                ("comp-first /...", "/..."),
690                ("comp-second /...", "/..."),
691                ("comp-first /whee/...", "/whee/..."),
692                ("comp-second /whee/...", "/whee/..."),
693            ],
694            Some(&mut duplicates),
695        )
696        .unwrap();
697
698        assert_eq!("comp-first /", duplicates[0].replaced_id);
699        assert_eq!("/", duplicates[0].route());
700
701        assert_eq!("comp-first /foo", duplicates[1].replaced_id);
702        assert_eq!("/foo", duplicates[1].route());
703
704        assert_eq!("comp-first /...", duplicates[2].replaced_id);
705        assert_eq!("/...", duplicates[2].route());
706
707        assert_eq!("comp-first /whee/...", duplicates[3].replaced_id);
708        assert_eq!("/whee/...", duplicates[3].route());
709    }
710
711    #[test]
712    fn unroutable_routes_are_skipped() {
713        let routes = Router::build(
714            "/",
715            [
716                (&component_key("comp-/"), &"/".into()),
717                (&component_key("comp-/foo"), &"/foo".into()),
718                (
719                    &component_key("comp-private"),
720                    &HttpTriggerRouteConfig::Private(HttpPrivateEndpoint { private: true }),
721                ),
722                (&component_key("comp-/whee/..."), &"/whee/...".into()),
723            ],
724            None,
725        )
726        .unwrap();
727
728        assert_eq!(3, routes.routes().count());
729        assert!(!routes
730            .routes()
731            .any(|(_r, tcr)| tcr.component_id() == "comp-private"));
732    }
733
734    #[test]
735    fn unroutable_routes_have_to_be_unroutable_thats_just_common_sense() {
736        let e = Router::build(
737            "/",
738            vec![
739                (&component_key("comp-/"), &"/".into()),
740                (&component_key("comp-/foo"), &"/foo".into()),
741                (
742                    &component_key("comp-bad component"),
743                    &HttpTriggerRouteConfig::Private(HttpPrivateEndpoint { private: false }),
744                ),
745                (&component_key("comp-/whee/..."), &"/whee/...".into()),
746            ],
747            None,
748        )
749        .expect_err("should not have accepted a 'route = true'");
750
751        assert!(e.to_string().contains("comp-bad component"));
752    }
753
754    #[test]
755    fn trailing_wildcard_is_captured() {
756        let routes = component_router("/", [("comp", "/...")], None).unwrap();
757        let m = routes.route("/1/2/3").expect("/1/2/3 should have matched");
758        assert_eq!("/1/2/3", m.trailing_wildcard());
759
760        let routes = component_router("/", [("comp", "/1/...")], None).unwrap();
761        let m = routes.route("/1/2/3").expect("/1/2/3 should have matched");
762        assert_eq!("/2/3", m.trailing_wildcard());
763    }
764
765    #[test]
766    fn trailing_wildcard_respects_trailing_slash() {
767        // We test this because it is the existing Spin behaviour but is *not*
768        // how routefinder behaves by default (routefinder prefers to ignore trailing
769        // slashes).
770        let routes = component_router("/", [("comp", "/test/...")], None).unwrap();
771        let m = routes.route("/test").expect("/test should have matched");
772        assert_eq!("", m.trailing_wildcard());
773        let m = routes.route("/test/").expect("/test/ should have matched");
774        assert_eq!("/", m.trailing_wildcard());
775        let m = routes
776            .route("/test/hello")
777            .expect("/test/hello should have matched");
778        assert_eq!("/hello", m.trailing_wildcard());
779        let m = routes
780            .route("/test/hello/")
781            .expect("/test/hello/ should have matched");
782        assert_eq!("/hello/", m.trailing_wildcard());
783    }
784
785    #[test]
786    fn named_wildcard_is_captured() {
787        let routes = component_router("/", [("comp", "/1/:two/3")], None).unwrap();
788        let m = routes.route("/1/2/3").expect("/1/2/3 should have matched");
789        assert_eq!("2", m.named_wildcards()["two"]);
790
791        let routes = component_router("/", [("comp", "/1/:two/...")], None).unwrap();
792        let m = routes.route("/1/2/3").expect("/1/2/3 should have matched");
793        assert_eq!("2", m.named_wildcards()["two"]);
794    }
795
796    #[test]
797    fn reserved_routes_are_reserved() {
798        let routes = component_router("/", [("comp", "/.well-known/spin/...")], None).unwrap();
799        assert!(routes.contains_reserved_route());
800
801        let routes = component_router("/", [("comp", "/.well-known/spin/fie")], None).unwrap();
802        assert!(routes.contains_reserved_route());
803    }
804
805    #[test]
806    fn unreserved_routes_are_unreserved() {
807        let routes = component_router("/", [("comp", "/.well-known/spindle/...")], None).unwrap();
808        assert!(!routes.contains_reserved_route());
809
810        let routes = component_router("/", [("comp", "/.well-known/spi/...")], None).unwrap();
811        assert!(!routes.contains_reserved_route());
812
813        let routes = component_router("/", [("comp", "/.well-known/spin")], None).unwrap();
814        assert!(!routes.contains_reserved_route());
815    }
816}