Skip to content

Routing modes #66

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 29, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 76 additions & 2 deletions src/http/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,42 @@ pub type Params = Captures<'static, 'static>;
/// # fn handle_single_segment(req: Request, params: Params) -> anyhow::Result<Response> { todo!() }
/// # fn handle_exact(req: Request, params: Params) -> anyhow::Result<Response> { todo!() }
/// ```
///
/// Route based on the trailing segment of a Spin wildcard route, instead of on the full path
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this follows an existing pattern, but it somehow feels weird that the example code here is on the struct itself instead of on the method. But maybe I would feel the same way if it were the other way around?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rylev Good point. My rough thinking is that these don't document handle() or suffix() in isolation, but rather the interaction between new()/suffix(), get/post/put/etc, and handle() - that is, they are examples of "how to use the Router struct", rather than an individual method in isolation. It does lead to a rather large and hard to navigate docs block though.

And there's something to be said for also having examples on individual methods, because a reader might have an anchored link that takes them directly to a method and bypasses the struct docs.

For now, I'm going to punt on this for this PR, and we can re-assess and/or try different things as part of a separate examples pass.

///
/// ```no_run
/// // spin.toml
/// //
/// // [[trigger.http]]
/// // route = "/shop/..."
///
/// // component
/// # use spin_sdk::http::{IntoResponse, Params, Request, Response, Router};
/// fn handle_route(req: Request) -> Response {
/// let mut router = Router::suffix();
/// router.any("/users/*", handle_users);
/// router.any("/products/*", handle_products);
/// router.handle(req)
/// }
///
/// // '/shop/users/1' is routed to `handle_users`
/// // '/shop/products/1' is routed to `handle_products`
/// # fn handle_users(req: Request, params: Params) -> anyhow::Result<Response> { todo!() }
/// # fn handle_products(req: Request, params: Params) -> anyhow::Result<Response> { todo!() }
/// ```
pub struct Router {
methods_map: HashMap<Method, MethodRouter<Box<dyn Handler>>>,
any_methods: MethodRouter<Box<dyn Handler>>,
route_on: RouteOn,
}

/// Describes what part of the path the Router will route on.
enum RouteOn {
/// The router will route on the full path.
FullPath,
/// The router expects the component to be handling a route with a trailing wildcard
/// (e.g. `route = /shop/...`), and will route on the trailing segment.
Suffix,
}

impl Default for Router {
Expand Down Expand Up @@ -203,7 +236,16 @@ impl Router {
Err(e) => return e.into_response(),
};
let method = request.method.clone();
let path = &request.path();
let path = match self.route_on {
RouteOn::FullPath => request.path(),
RouteOn::Suffix => match trailing_suffix(&request) {
Some(path) => path,
None => {
eprintln!("Internal error: Router configured with suffix routing but trigger route has no trailing wildcard");
return responses::internal_server_error();
}
},
};
let RouteMatch { params, handler } = self.find(path, method);
handler.handle(request, params).await
}
Expand Down Expand Up @@ -516,11 +558,22 @@ impl Router {
self.add_async(path, Method::Options, handler)
}

/// Construct a new Router.
/// Construct a new Router that matches on the full path.
pub fn new() -> Self {
Router {
methods_map: HashMap::default(),
any_methods: MethodRouter::new(),
route_on: RouteOn::FullPath,
}
}

/// Construct a new Router that matches on the trailing wildcard
/// component of the route.
pub fn suffix() -> Self {
Router {
methods_map: HashMap::default(),
any_methods: MethodRouter::new(),
route_on: RouteOn::Suffix,
}
}
}
Expand All @@ -533,6 +586,11 @@ async fn method_not_allowed(_req: Request, _params: Params) -> Response {
responses::method_not_allowed()
}

fn trailing_suffix(req: &Request) -> Option<&str> {
req.header("spin-path-info")
.and_then(|path_info| path_info.as_str())
}

/// A macro to help with constructing a Router from a stream of tokens.
#[macro_export]
macro_rules! http_router {
Expand Down Expand Up @@ -579,6 +637,12 @@ mod tests {
Request::new(method, path)
}

fn make_wildcard_request(method: Method, path: &str, trailing: &str) -> Request {
let mut req = Request::new(method, path);
req.set_header("spin-path-info", trailing);
req
}

fn echo_param(_req: Request, params: Params) -> Response {
match params.get("x") {
Some(path) => Response::new(200, path),
Expand Down Expand Up @@ -666,6 +730,16 @@ mod tests {
assert_eq!(res.body, "foo".to_owned().into_bytes());
}

#[test]
fn test_spin_trailing_wildcard() {
let mut router = Router::suffix();
router.get("/:x/*", echo_param);

let req = make_wildcard_request(Method::Get, "/base/baser/foo/bar", "/foo/bar");
let res = router.handle(req);
assert_eq!(res.body, "foo".to_owned().into_bytes());
}

#[test]
fn test_router_display() {
let mut router = Router::default();
Expand Down