Skip to main content

spin_http_routes/
lib.rs

1//! Route matching for the HTTP trigger.
2
3#![deny(missing_docs)]
4
5use anyhow::{Result, anyhow};
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(&self) -> impl Iterator<Item = (&impl RouteInfo, &TriggerLookupKey)> {
168        self.router
169            .iter()
170            .map(|(_spec, handler)| (&handler.parsed_based_route, &handler.lookup_key))
171    }
172
173    /// true if one or more routes is under the reserved `/.well-known/spin/*`
174    /// prefix; otherwise false.
175    pub fn contains_reserved_route(&self) -> bool {
176        self.router
177            .iter()
178            .any(|(_spec, handler)| handler.based_route.starts_with(crate::WELL_KNOWN_PREFIX))
179    }
180
181    /// This returns the component ID that should handle the given path, or an error
182    /// if no component matches.
183    ///
184    /// If multiple components could potentially handle the same request based on their
185    /// defined routes, components with matching exact routes take precedence followed
186    /// by matching wildcard patterns with the longest matching prefix.
187    pub fn route<'path, 'router: 'path>(
188        &'router self,
189        path: &'path str,
190    ) -> Result<RouteMatch<'router, 'path>> {
191        let best_match = self
192            .router
193            .best_match(path)
194            .ok_or_else(|| anyhow!("Cannot match route for path {path}"))?;
195
196        let route_handler = best_match.handler();
197        let captures = best_match.captures();
198
199        Ok(RouteMatch {
200            inner: RouteMatchKind::Real {
201                route_handler,
202                captures,
203                path,
204            },
205        })
206    }
207}
208
209impl DuplicateRoute {
210    /// The duplicated route pattern.
211    pub fn route(&self) -> &str {
212        if self.route.is_empty() {
213            "/"
214        } else {
215            &self.route
216        }
217    }
218}
219
220/// Information about a parsed route.
221pub trait RouteInfo: fmt::Display + fmt::Debug {
222    /// Returns the route path without any wildcard annotation.
223    fn path(&self) -> &str;
224    /// Returns true if this route has a trailing wildcard.
225    fn is_wildcard(&self) -> bool;
226}
227
228#[derive(Clone, Debug)]
229enum ParsedRoute {
230    Exact(String),
231    TrailingWildcard(String),
232}
233
234impl ParsedRoute {
235    fn exact(route: impl Into<String>) -> Self {
236        Self::Exact(route.into())
237    }
238
239    fn trailing_wildcard(route: impl Into<String>) -> Self {
240        Self::TrailingWildcard(route.into())
241    }
242}
243
244impl RouteInfo for ParsedRoute {
245    fn path(&self) -> &str {
246        let p = match self {
247            ParsedRoute::Exact(path) => path,
248            ParsedRoute::TrailingWildcard(pattern) => pattern,
249        };
250        if p.is_empty() { "/" } else { p }
251    }
252
253    fn is_wildcard(&self) -> bool {
254        matches!(self, ParsedRoute::TrailingWildcard(_))
255    }
256}
257
258impl fmt::Display for ParsedRoute {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        match &self {
261            ParsedRoute::Exact(path) => write!(f, "{path}"),
262            ParsedRoute::TrailingWildcard(pattern) => write!(f, "{pattern} (wildcard)"),
263        }
264    }
265}
266
267/// A routing match for a URL.
268pub struct RouteMatch<'router, 'path> {
269    inner: RouteMatchKind<'router, 'path>,
270}
271
272impl RouteMatch<'_, '_> {
273    /// A synthetic match as if the given path was matched against the wildcard route.
274    /// Used in service chaining; always directs to a component.
275    pub fn synthetic(component_id: String, path: String) -> Self {
276        Self {
277            inner: RouteMatchKind::Synthetic {
278                route_handler: RouteHandler {
279                    lookup_key: TriggerLookupKey::Component(component_id),
280                    based_route: "/...".into(),
281                    raw_route: "/...".into(),
282                    parsed_based_route: ParsedRoute::TrailingWildcard(String::new()),
283                },
284                trailing_wildcard: path,
285            },
286        }
287    }
288
289    /// An identifier for looking up the matched handler.
290    pub fn lookup_key(&self) -> &TriggerLookupKey {
291        &self.inner.route_handler().lookup_key
292    }
293
294    /// The matched route, as originally written in the manifest, combined with the base.
295    pub fn based_route(&self) -> &str {
296        &self.inner.route_handler().based_route
297    }
298
299    /// The matched route, excluding any trailing wildcard, combined with the base.
300    pub fn based_route_or_prefix(&self) -> &str {
301        self.inner
302            .route_handler()
303            .based_route
304            .strip_suffix("/...")
305            .unwrap_or(&self.inner.route_handler().based_route)
306    }
307
308    /// The matched route, as originally written in the manifest.
309    pub fn raw_route(&self) -> &str {
310        &self.inner.route_handler().raw_route
311    }
312
313    /// The matched route, excluding any trailing wildcard.
314    pub fn raw_route_or_prefix(&self) -> &str {
315        self.inner
316            .route_handler()
317            .raw_route
318            .strip_suffix("/...")
319            .unwrap_or(&self.inner.route_handler().raw_route)
320    }
321
322    /// The named wildcards captured from the path, if any
323    pub fn named_wildcards(&self) -> HashMap<&str, &str> {
324        self.inner.named_wildcards()
325    }
326
327    /// The trailing wildcard part of the path, if any
328    pub fn trailing_wildcard(&self) -> Cow<'_, str> {
329        self.inner.trailing_wildcard()
330    }
331}
332
333/// The kind of route match that was made.
334///
335/// Can either be real based on the routefinder or synthetic based on hardcoded results.
336enum RouteMatchKind<'router, 'path> {
337    /// A synthetic match as if the given path was matched against the wildcard route.
338    Synthetic {
339        /// The route handler that matched the path.
340        route_handler: RouteHandler,
341        /// The trailing wildcard part of the path
342        trailing_wildcard: String,
343    },
344    /// A real match.
345    Real {
346        /// The route handler that matched the path.
347        route_handler: &'router RouteHandler,
348        /// The best match for the path.
349        captures: routefinder::Captures<'router, 'path>,
350        /// The path that was matched.
351        path: &'path str,
352    },
353}
354
355impl RouteMatchKind<'_, '_> {
356    /// The route handler that matched the path.
357    fn route_handler(&self) -> &RouteHandler {
358        match self {
359            RouteMatchKind::Synthetic { route_handler, .. } => route_handler,
360            RouteMatchKind::Real { route_handler, .. } => route_handler,
361        }
362    }
363
364    /// The named wildcards captured from the path, if any
365    pub fn named_wildcards(&self) -> HashMap<&str, &str> {
366        let Self::Real { captures, .. } = &self else {
367            return HashMap::new();
368        };
369        captures.iter().collect()
370    }
371
372    /// The trailing wildcard part of the path, if any
373    pub fn trailing_wildcard(&self) -> Cow<'_, str> {
374        let (captures, path) = match self {
375            // If we have a synthetic match, we already have the trailing wildcard.
376            Self::Synthetic {
377                trailing_wildcard, ..
378            } => return trailing_wildcard.into(),
379            Self::Real { captures, path, .. } => (captures, path),
380        };
381
382        captures
383            .wildcard()
384            .map(|s|
385            // Backward compatibility considerations - Spin has traditionally
386            // captured trailing slashes, but routefinder does not.
387            match (s.is_empty(), path.ends_with('/')) {
388                // route: /foo/..., path: /foo
389                (true, false) => s.into(),
390                // route: /foo/..., path: /foo/
391                (true, true) => "/".into(),
392                // route: /foo/..., path: /foo/bar
393                (false, false) => format!("/{s}").into(),
394                // route: /foo/..., path: /foo/bar/
395                (false, true) => format!("/{s}/").into(),
396            })
397            .unwrap_or_default()
398    }
399}
400
401/// Sanitizes the base and path and return a formed path.
402fn sanitize_with_base<S: Into<String>>(base: S, path: S) -> String {
403    let path = absolutize(path);
404
405    format!("{}{}", sanitize(base.into()), sanitize(path))
406}
407
408fn absolutize<S: Into<String>>(s: S) -> String {
409    let s = s.into();
410    if s.starts_with('/') {
411        s
412    } else {
413        format!("/{s}")
414    }
415}
416
417/// Strips the trailing slash from a string.
418fn sanitize<S: Into<String>>(s: S) -> String {
419    let s = s.into();
420    // TODO
421    // This only strips a single trailing slash.
422    // Should we attempt to strip all trailing slashes?
423    match s.strip_suffix('/') {
424        Some(s) => s.into(),
425        None => s,
426    }
427}
428
429/// An HTTP trigger route
430#[derive(Clone, Debug, Deserialize, Serialize)]
431#[serde(untagged)]
432pub enum HttpTriggerRouteConfig {
433    /// A route that is routable.
434    Route(String),
435    /// A route that is not routable, but indicates a private endpoint.
436    Private(HttpPrivateEndpoint),
437}
438
439/// Indicates that a trigger is a private endpoint (not routable).
440#[derive(Clone, Debug, Default, Deserialize, Serialize)]
441#[serde(deny_unknown_fields)]
442pub struct HttpPrivateEndpoint {
443    /// Whether the private endpoint is private. This must be true.
444    pub private: bool,
445}
446
447impl Default for HttpTriggerRouteConfig {
448    fn default() -> Self {
449        Self::Route(Default::default())
450    }
451}
452
453impl<T: Into<String>> From<T> for HttpTriggerRouteConfig {
454    fn from(value: T) -> Self {
455        Self::Route(value.into())
456    }
457}
458
459#[cfg(test)]
460mod route_tests {
461    use super::*;
462
463    fn component_key(value: &str) -> TriggerLookupKey {
464        TriggerLookupKey::Component(value.to_string())
465    }
466
467    fn trigger_key(value: &str) -> TriggerLookupKey {
468        TriggerLookupKey::Trigger(value.to_string())
469    }
470
471    impl TriggerLookupKey {
472        fn component_id(&self) -> &str {
473            match self {
474                TriggerLookupKey::Component(id) => id,
475                TriggerLookupKey::Trigger(_) => {
476                    panic!("expected component ref but was trigger ref")
477                }
478            }
479        }
480    }
481
482    /// Produces a router using component routes only
483    fn component_router<'a>(
484        base: &str,
485        components: impl IntoIterator<Item = (&'a str, &'a str)>,
486        duplicate_routes: Option<&mut Vec<DuplicateRoute>>,
487    ) -> anyhow::Result<Router> {
488        let owned_routes = components
489            .into_iter()
490            .map(|(cid, path)| (component_key(cid), HttpTriggerRouteConfig::from(path)))
491            .collect::<Vec<_>>();
492        let routes = owned_routes.iter().map(|(k, v)| (k, v)); // Yes, I'm afraid this is necessary
493
494        Router::build(base, routes, duplicate_routes)
495    }
496
497    impl RouteMatch<'_, '_> {
498        fn component_id(&self) -> &str {
499            self.lookup_key().component_id()
500        }
501    }
502
503    #[test]
504    fn test_router_exact() -> Result<()> {
505        let r = component_router("/", [("foo", "/foo"), ("foobar", "/foo/bar")], None)?;
506
507        assert_eq!(r.route("/foo")?.component_id(), "foo");
508        assert_eq!(r.route("/foo/bar")?.component_id(), "foobar");
509        Ok(())
510    }
511
512    #[test]
513    fn router_returns_trigger_or_component() -> Result<()> {
514        let r = Router::build(
515            "/",
516            [
517                (&component_key("compy"), &"/foo".into()),
518                (&trigger_key("triggy"), &"/foo/bar".into()),
519            ],
520            None,
521        )?;
522
523        assert!(
524            matches!(r.route("/foo")?.lookup_key(), TriggerLookupKey::Component(c) if c == "compy")
525        );
526        assert!(
527            matches!(r.route("/foo/bar")?.lookup_key(), TriggerLookupKey::Trigger(t) if t == "triggy")
528        );
529        Ok(())
530    }
531
532    #[test]
533    fn test_router_respects_base() -> Result<()> {
534        let r = component_router("/base", [("foo", "/foo"), ("foobar", "/foo/bar")], None)?;
535
536        assert_eq!(r.route("/base/foo")?.component_id(), "foo");
537        assert_eq!(r.route("/base/foo/bar")?.component_id(), "foobar");
538        Ok(())
539    }
540
541    #[test]
542    fn test_router_wildcard() -> Result<()> {
543        let r = component_router("/", [("all", "/...")], None)?;
544
545        assert_eq!(r.route("/foo/bar")?.component_id(), "all");
546        assert_eq!(r.route("/abc/")?.component_id(), "all");
547        assert_eq!(r.route("/")?.component_id(), "all");
548        assert_eq!(
549            r.route("/this/should/be/captured?abc=def")?.component_id(),
550            "all"
551        );
552        Ok(())
553    }
554
555    #[test]
556    fn wildcard_routes_use_custom_display() {
557        let routes = component_router("/", vec![("comp", "/whee/...")], None).unwrap();
558
559        let (route, rh) = routes.routes().next().unwrap();
560
561        assert_eq!("comp", rh.component_id());
562        assert_eq!("/whee (wildcard)", format!("{route}"));
563    }
564
565    #[test]
566    fn test_router_respects_longest_match() -> Result<()> {
567        let r = component_router(
568            "/",
569            [
570                ("one_wildcard", "/one/..."),
571                ("onetwo_wildcard", "/one/two/..."),
572                ("onetwothree_wildcard", "/one/two/three/..."),
573            ],
574            None,
575        )?;
576
577        assert_eq!(
578            r.route("/one/two/three/four")?.component_id(),
579            "onetwothree_wildcard"
580        );
581
582        // ...regardless of order
583        let r = component_router(
584            "/",
585            [
586                ("onetwothree_wildcard", "/one/two/three/..."),
587                ("onetwo_wildcard", "/one/two/..."),
588                ("one_wildcard", "/one/..."),
589            ],
590            None,
591        )?;
592
593        assert_eq!(
594            r.route("/one/two/three/four")?.component_id(),
595            "onetwothree_wildcard"
596        );
597        Ok(())
598    }
599
600    #[test]
601    fn test_router_exact_beats_wildcard() -> Result<()> {
602        let r = component_router("/", [("one_exact", "/one"), ("wildcard", "/...")], None)?;
603
604        assert_eq!(r.route("/one")?.component_id(), "one_exact");
605
606        Ok(())
607    }
608
609    #[test]
610    fn sensible_routes_are_reachable() {
611        let mut duplicates = Vec::new();
612        let routes = component_router(
613            "/",
614            [
615                ("/", "/"),
616                ("/foo", "/foo"),
617                ("/bar", "/bar"),
618                ("/whee/...", "/whee/..."),
619            ],
620            Some(&mut duplicates),
621        )
622        .unwrap();
623
624        assert_eq!(4, routes.routes().count());
625        assert_eq!(0, duplicates.len());
626    }
627
628    #[test]
629    fn order_of_reachable_routes_is_preserved() {
630        let routes = component_router(
631            "/",
632            [
633                ("comp-/", "/"),
634                ("comp-/foo", "/foo"),
635                ("comp-/bar", "/bar"),
636                ("comp-/whee/...", "/whee/..."),
637            ],
638            None,
639        )
640        .unwrap();
641
642        assert_eq!("comp-/", routes.routes().next().unwrap().1.component_id());
643        assert_eq!(
644            "comp-/foo",
645            routes.routes().nth(1).unwrap().1.component_id()
646        );
647        assert_eq!(
648            "comp-/bar",
649            routes.routes().nth(2).unwrap().1.component_id()
650        );
651        assert_eq!(
652            "comp-/whee/...",
653            routes.routes().nth(3).unwrap().1.component_id()
654        );
655    }
656
657    #[test]
658    fn duplicate_routes_are_unreachable() {
659        let mut duplicates = Vec::new();
660        let routes = component_router(
661            "/",
662            [
663                ("comp-/", "/"),
664                ("comp-first /foo", "/foo"),
665                ("comp-second /foo", "/foo"),
666                ("comp-/whee/...", "/whee/..."),
667            ],
668            Some(&mut duplicates),
669        )
670        .unwrap();
671
672        assert_eq!(3, routes.routes().count());
673        assert_eq!(1, duplicates.len());
674    }
675
676    #[test]
677    fn duplicate_routes_last_one_wins() {
678        let mut duplicates = Vec::new();
679        let routes = component_router(
680            "/",
681            [
682                ("comp-/", "/"),
683                ("comp-first /foo", "/foo"),
684                ("comp-second /foo", "/foo"),
685                ("comp-/whee/...", "/whee/..."),
686            ],
687            Some(&mut duplicates),
688        )
689        .unwrap();
690
691        assert_eq!(
692            "comp-second /foo",
693            routes.routes().nth(1).unwrap().1.component_id()
694        );
695        assert_eq!("comp-first /foo", duplicates[0].replaced_id);
696        assert_eq!("comp-second /foo", duplicates[0].effective_id);
697    }
698
699    #[test]
700    fn duplicate_routes_reporting_is_faithful() {
701        let mut duplicates = Vec::new();
702        let _ = component_router(
703            "/",
704            [
705                ("comp-first /", "/"),
706                ("comp-second /", "/"),
707                ("comp-first /foo", "/foo"),
708                ("comp-second /foo", "/foo"),
709                ("comp-first /...", "/..."),
710                ("comp-second /...", "/..."),
711                ("comp-first /whee/...", "/whee/..."),
712                ("comp-second /whee/...", "/whee/..."),
713            ],
714            Some(&mut duplicates),
715        )
716        .unwrap();
717
718        assert_eq!("comp-first /", duplicates[0].replaced_id);
719        assert_eq!("/", duplicates[0].route());
720
721        assert_eq!("comp-first /foo", duplicates[1].replaced_id);
722        assert_eq!("/foo", duplicates[1].route());
723
724        assert_eq!("comp-first /...", duplicates[2].replaced_id);
725        assert_eq!("/...", duplicates[2].route());
726
727        assert_eq!("comp-first /whee/...", duplicates[3].replaced_id);
728        assert_eq!("/whee/...", duplicates[3].route());
729    }
730
731    #[test]
732    fn unroutable_routes_are_skipped() {
733        let routes = Router::build(
734            "/",
735            [
736                (&component_key("comp-/"), &"/".into()),
737                (&component_key("comp-/foo"), &"/foo".into()),
738                (
739                    &component_key("comp-private"),
740                    &HttpTriggerRouteConfig::Private(HttpPrivateEndpoint { private: true }),
741                ),
742                (&component_key("comp-/whee/..."), &"/whee/...".into()),
743            ],
744            None,
745        )
746        .unwrap();
747
748        assert_eq!(3, routes.routes().count());
749        assert!(
750            !routes
751                .routes()
752                .any(|(_r, tcr)| tcr.component_id() == "comp-private")
753        );
754    }
755
756    #[test]
757    fn unroutable_routes_have_to_be_unroutable_thats_just_common_sense() {
758        let e = Router::build(
759            "/",
760            vec![
761                (&component_key("comp-/"), &"/".into()),
762                (&component_key("comp-/foo"), &"/foo".into()),
763                (
764                    &component_key("comp-bad component"),
765                    &HttpTriggerRouteConfig::Private(HttpPrivateEndpoint { private: false }),
766                ),
767                (&component_key("comp-/whee/..."), &"/whee/...".into()),
768            ],
769            None,
770        )
771        .expect_err("should not have accepted a 'route = true'");
772
773        assert!(e.to_string().contains("comp-bad component"));
774    }
775
776    #[test]
777    fn trailing_wildcard_is_captured() {
778        let routes = component_router("/", [("comp", "/...")], None).unwrap();
779        let m = routes.route("/1/2/3").expect("/1/2/3 should have matched");
780        assert_eq!("/1/2/3", m.trailing_wildcard());
781
782        let routes = component_router("/", [("comp", "/1/...")], None).unwrap();
783        let m = routes.route("/1/2/3").expect("/1/2/3 should have matched");
784        assert_eq!("/2/3", m.trailing_wildcard());
785    }
786
787    #[test]
788    fn trailing_wildcard_respects_trailing_slash() {
789        // We test this because it is the existing Spin behaviour but is *not*
790        // how routefinder behaves by default (routefinder prefers to ignore trailing
791        // slashes).
792        let routes = component_router("/", [("comp", "/test/...")], None).unwrap();
793        let m = routes.route("/test").expect("/test should have matched");
794        assert_eq!("", m.trailing_wildcard());
795        let m = routes.route("/test/").expect("/test/ should have matched");
796        assert_eq!("/", m.trailing_wildcard());
797        let m = routes
798            .route("/test/hello")
799            .expect("/test/hello should have matched");
800        assert_eq!("/hello", m.trailing_wildcard());
801        let m = routes
802            .route("/test/hello/")
803            .expect("/test/hello/ should have matched");
804        assert_eq!("/hello/", m.trailing_wildcard());
805    }
806
807    #[test]
808    fn named_wildcard_is_captured() {
809        let routes = component_router("/", [("comp", "/1/:two/3")], None).unwrap();
810        let m = routes.route("/1/2/3").expect("/1/2/3 should have matched");
811        assert_eq!("2", m.named_wildcards()["two"]);
812
813        let routes = component_router("/", [("comp", "/1/:two/...")], None).unwrap();
814        let m = routes.route("/1/2/3").expect("/1/2/3 should have matched");
815        assert_eq!("2", m.named_wildcards()["two"]);
816    }
817
818    #[test]
819    fn reserved_routes_are_reserved() {
820        let routes = component_router("/", [("comp", "/.well-known/spin/...")], None).unwrap();
821        assert!(routes.contains_reserved_route());
822
823        let routes = component_router("/", [("comp", "/.well-known/spin/fie")], None).unwrap();
824        assert!(routes.contains_reserved_route());
825    }
826
827    #[test]
828    fn unreserved_routes_are_unreserved() {
829        let routes = component_router("/", [("comp", "/.well-known/spindle/...")], None).unwrap();
830        assert!(!routes.contains_reserved_route());
831
832        let routes = component_router("/", [("comp", "/.well-known/spi/...")], None).unwrap();
833        assert!(!routes.contains_reserved_route());
834
835        let routes = component_router("/", [("comp", "/.well-known/spin")], None).unwrap();
836        assert!(!routes.contains_reserved_route());
837    }
838}