spin_http/
routes.rs

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