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