Merge pull request #1339 from fibonacci1729/rust-sdk-http-router

feat(sdk): add HTTP router for Rust SDK
This commit is contained in:
Brian 2023-04-04 09:52:19 -04:00 committed by GitHub
commit 8d0b6c7737
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 638 additions and 10 deletions

31
Cargo.lock generated
View File

@ -4248,6 +4248,16 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422"
[[package]]
name = "routefinder"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f8f99b10dedd317514253dda1fa7c14e344aac96e1f78149a64879ce282aca"
dependencies = [
"smartcow",
"smartstring",
]
[[package]]
name = "rpassword"
version = "7.2.0"
@ -4804,6 +4814,26 @@ version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
[[package]]
name = "smartcow"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "656fcb1c1fca8c4655372134ce87d8afdf5ec5949ebabe8d314be0141d8b5da2"
dependencies = [
"smartstring",
]
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"static_assertions",
"version_check",
]
[[package]]
name = "socket2"
version = "0.4.9"
@ -5158,6 +5188,7 @@ dependencies = [
"bytes",
"form_urlencoded",
"http",
"routefinder",
"spin-macro",
"thiserror",
"wit-bindgen-rust",

View File

@ -0,0 +1,2 @@
[build]
target = "wasm32-wasi"

View File

@ -0,0 +1 @@
target/

View File

@ -0,0 +1,21 @@
[package]
name = "http-rust-router"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = [ "cdylib" ]
[dependencies]
# Useful crate to handle errors.
anyhow = "1"
# Crate to simplify working with bytes.
bytes = "1"
# General-purpose crate with common HTTP types.
http = "0.2"
# The Spin SDK.
spin-sdk = { path = "../../sdk/rust" }
# Crate that generates Rust Wasm bindings from a WebAssembly interface.
wit-bindgen-rust = { git = "https://github.com/bytecodealliance/wit-bindgen", rev = "cb871cfa1ee460b51eb1d144b175b9aab9c50aba" }
[workspace]

View File

@ -0,0 +1,16 @@
spin_manifest_version = "1"
authors = ["Fermyon Engineering <engineering@fermyon.com>"]
description = "An application that demonstrates HTTP routing."
name = "spin-rust-router"
trigger = { type = "http", base = "/" }
version = "1.0.0"
[[component]]
id = "route"
source = "target/wasm32-wasi/release/http_rust_router.wasm"
description = "A component that internally routes HTTP requests."
[component.trigger]
route = "/..."
[component.build]
command = "cargo build --target wasm32-wasi --release"
watch = ["src/**/*.rs", "Cargo.toml", "spin.toml"]

View File

@ -0,0 +1,39 @@
use anyhow::Result;
use spin_sdk::{
http_component,
http_router,
http::{
Request,
Response,
Params,
},
};
#[http_component]
fn handle_route(req: Request) -> anyhow::Result<Response> {
let router = http_router! {
GET "/hello/:planet" => api::hello_planet,
_ "/*" => |_req, params| {
let capture = params.wildcard().unwrap_or_default();
Ok(http::Response::builder()
.status(http::StatusCode::OK)
.body(Some(format!("{capture}").into()))
.unwrap())
}
};
router.handle(req)
}
mod api {
use super::*;
// /hello/:planet
pub fn hello_planet(_req: Request, params: Params) -> anyhow::Result<Response> {
let planet = params.get("planet").expect("PLANET");
Ok(http::Response::builder()
.status(http::StatusCode::OK)
.body(Some(format!("{planet}").into()))
.unwrap())
}
}

View File

@ -0,0 +1,2 @@
[build]
target = "wasm32-wasi"

1
examples/http-rust-router/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
target/

View File

@ -0,0 +1,21 @@
[package]
name = "http-rust-router"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = [ "cdylib" ]
[dependencies]
# Useful crate to handle errors.
anyhow = "1"
# Crate to simplify working with bytes.
bytes = "1"
# General-purpose crate with common HTTP types.
http = "0.2"
# The Spin SDK.
spin-sdk = { path = "../../sdk/rust" }
# Crate that generates Rust Wasm bindings from a WebAssembly interface.
wit-bindgen-rust = { git = "https://github.com/bytecodealliance/wit-bindgen", rev = "cb871cfa1ee460b51eb1d144b175b9aab9c50aba" }
[workspace]

View File

@ -0,0 +1,16 @@
spin_manifest_version = "1"
authors = ["Fermyon Engineering <engineering@fermyon.com>"]
description = "An application that demonstrates HTTP routing."
name = "spin-rust-router"
trigger = { type = "http", base = "/" }
version = "1.0.0"
[[component]]
id = "route"
source = "target/wasm32-wasi/release/http_rust_router.wasm"
description = "A component that internally routes HTTP requests."
[component.trigger]
route = "/..."
[component.build]
command = "cargo build --target wasm32-wasi --release"
watch = ["src/**/*.rs", "Cargo.toml", "spin.toml"]

View File

@ -0,0 +1,40 @@
use anyhow::Result;
use spin_sdk::{
http_component,
http::{
Request,
Response,
Router,
Params,
},
};
/// A Spin HTTP component that internally routes requests.
#[http_component]
fn handle_route(req: Request) -> Result<Response> {
let mut router = Router::new();
router.get("/hello/:planet", api::hello_planet);
router.any("/*", api::echo_wildcard);
router.handle(req)
}
mod api {
use super::*;
// /hello/:planet
pub fn hello_planet(_req: Request, params: Params) -> Result<Response> {
let planet = params.get("planet").expect("PLANET");
Ok(http::Response::builder()
.status(http::StatusCode::OK)
.body(Some(format!("{planet}").into()))?)
}
// /*
pub fn echo_wildcard(_req: Request, params: Params) -> Result<Response> {
let capture = params.wildcard().unwrap_or_default();
Ok(http::Response::builder()
.status(http::StatusCode::OK)
.body(Some(format!("{capture}").into()))?)
}
}

View File

@ -12,10 +12,11 @@ name = "spin_sdk"
anyhow = "1"
bytes = "1"
form_urlencoded = "1.0"
http = "0.2"
http_types = { package = "http", version = "0.2" }
spin-macro = { path = "macro" }
thiserror = "1.0.37"
wit-bindgen-rust = { git = "https://github.com/bytecodealliance/wit-bindgen", rev = "cb871cfa1ee460b51eb1d144b175b9aab9c50aba" }
routefinder = "0.5.3"
[features]
default = [ "export-sdk-language" ]

347
sdk/rust/src/http/router.rs Normal file
View File

@ -0,0 +1,347 @@
use super::{Request, Response, Result};
use routefinder::{Captures, Router as MethodRouter};
use std::collections::HashMap;
type Handler = dyn Fn(Request, Params) -> Result<Response>;
/// Route parameters extracted from a URI that match a route pattern.
pub type Params = Captures<'static, 'static>;
/// The Spin SDK HTTP router.
pub struct Router {
methods_map: HashMap<http_types::Method, MethodRouter<Box<Handler>>>,
any_methods: MethodRouter<Box<Handler>>,
}
impl Default for Router {
fn default() -> Router {
Router::new()
}
}
struct RouteMatch<'a> {
params: Captures<'static, 'static>,
handler: &'a Handler,
}
impl Router {
/// Dispatches a request to the appropriate handler along with the URI parameters.
pub fn handle(&self, request: Request) -> Result<Response> {
let method = request.method().to_owned();
let path = request.uri().path().to_owned();
let RouteMatch { params, handler } = self.find(&path, method);
handler(request, params)
}
fn find(&self, path: &str, method: http_types::Method) -> RouteMatch<'_> {
let best_match = self
.methods_map
.get(&method)
.and_then(|r| r.best_match(path));
if let Some(m) = best_match {
let params = m.captures().into_owned();
let handler = m.handler();
return RouteMatch { handler, params };
}
let best_match = self.any_methods.best_match(path);
match best_match {
Some(m) => {
let params = m.captures().into_owned();
let handler = m.handler();
RouteMatch { handler, params }
}
None if method == http_types::Method::HEAD => {
// If it is a HTTP HEAD request then check if there is a callback in the methods map
// if not then fallback to the behavior of HTTP GET else proceed as usual
self.find(path, http_types::Method::GET)
}
None => {
// Handle the failure case where no match could be resolved.
self.fail(path, method)
}
}
}
// Helper function to handle the case where a best match couldn't be resolved.
fn fail(&self, path: &str, method: http_types::Method) -> RouteMatch<'_> {
// First, filter all routers to determine if the path can match but the provided method is not allowed.
let is_method_not_allowed = self
.methods_map
.iter()
.filter(|(k, _)| **k != method)
.any(|(_, r)| r.best_match(path).is_some());
if is_method_not_allowed {
// If this `path` can be handled by a callback registered with a different HTTP method
// should return 405 Method Not Allowed
RouteMatch {
handler: &method_not_allowed,
params: Captures::default(),
}
} else {
// ... Otherwise, nothing matched so 404.
RouteMatch {
handler: &not_found,
params: Captures::default(),
}
}
}
/// Register a handler at the path for all methods.
pub fn any<F>(&mut self, path: &str, handler: F)
where
F: Fn(Request, Params) -> Result<Response> + 'static,
{
self.any_methods.add(path, Box::new(handler)).unwrap();
}
/// Register a handler at the path for the specified HTTP method.
pub fn add<F>(&mut self, path: &str, method: http_types::Method, handler: F)
where
F: Fn(Request, Params) -> Result<Response> + 'static,
{
self.methods_map
.entry(method)
.or_insert_with(MethodRouter::new)
.add(path, Box::new(handler))
.unwrap();
}
/// Register a handler at the path for the HTTP GET method.
pub fn get<F>(&mut self, path: &str, handler: F)
where
F: Fn(Request, Params) -> Result<Response> + 'static,
{
self.add(path, http_types::Method::GET, handler)
}
/// Register a handler at the path for the HTTP HEAD method.
pub fn head<F>(&mut self, path: &str, handler: F)
where
F: Fn(Request, Params) -> Result<Response> + 'static,
{
self.add(path, http_types::Method::HEAD, handler)
}
/// Register a handler at the path for the HTTP POST method.
pub fn post<F>(&mut self, path: &str, handler: F)
where
F: Fn(Request, Params) -> Result<Response> + 'static,
{
self.add(path, http_types::Method::POST, handler)
}
/// Register a handler at the path for the HTTP DELETE method.
pub fn delete<F>(&mut self, path: &str, handler: F)
where
F: Fn(Request, Params) -> Result<Response> + 'static,
{
self.add(path, http_types::Method::DELETE, handler)
}
/// Register a handler at the path for the HTTP PUT method.
pub fn put<F>(&mut self, path: &str, handler: F)
where
F: Fn(Request, Params) -> Result<Response> + 'static,
{
self.add(path, http_types::Method::PUT, handler)
}
/// Register a handler at the path for the HTTP PATCH method.
pub fn patch<F>(&mut self, path: &str, handler: F)
where
F: Fn(Request, Params) -> Result<Response> + 'static,
{
self.add(path, http_types::Method::PATCH, handler)
}
/// Construct a new Router.
pub fn new() -> Self {
Router {
methods_map: HashMap::default(),
any_methods: MethodRouter::new(),
}
}
}
fn not_found(_req: Request, _params: Params) -> Result<Response> {
Ok(http_types::Response::builder()
.status(http_types::StatusCode::NOT_FOUND)
.body(None)
.unwrap())
}
fn method_not_allowed(_req: Request, _params: Params) -> Result<Response> {
Ok(http_types::Response::builder()
.status(http_types::StatusCode::METHOD_NOT_ALLOWED)
.body(None)
.unwrap())
}
/// A macro to help with constructing a Router from a stream of tokens.
#[macro_export]
macro_rules! http_router {
($($method:tt $path:literal => $h:expr),*) => {
{
let mut router = spin_sdk::http::Router::new();
$(
spin_sdk::http_router!(@build router $method $path => $h);
)*
router
}
};
(@build $r:ident HEAD $path:literal => $h:expr) => {
$r.head($path, $h);
};
(@build $r:ident GET $path:literal => $h:expr) => {
$r.get($path, $h);
};
(@build $r:ident PUT $path:literal => $h:expr) => {
$r.put($path, $h);
};
(@build $r:ident POST $path:literal => $h:expr) => {
$r.post($path, $h);
};
(@build $r:ident PATCH $path:literal => $h:expr) => {
$r.patch($path, $h);
};
(@build $r:ident DELETE $path:literal => $h:expr) => {
$r.delete($path, $h);
};
(@build $r:ident _ $path:literal => $h:expr) => {
$r.any($path, $h);
};
}
#[cfg(test)]
mod tests {
use super::*;
fn make_request(method: http_types::Method, path: &str) -> Request {
http_types::Request::builder()
.method(method)
.uri(path)
.body(None)
.unwrap()
}
fn echo_param(req: Request, params: Params) -> Result<Response> {
match params.get("x") {
Some(path) => Ok(http_types::Response::builder()
.status(http_types::StatusCode::OK)
.body(Some(path.to_string().into()))?),
None => not_found(req, params),
}
}
#[test]
fn test_method_not_allowed() {
let mut router = Router::default();
router.get("/:x", echo_param);
let req = make_request(http_types::Method::POST, "/foobar");
let res = router.handle(req).unwrap();
assert_eq!(res.status(), http_types::StatusCode::METHOD_NOT_ALLOWED);
}
#[test]
fn test_not_found() {
fn h1(_req: Request, _params: Params) -> Result<Response> {
Ok(http_types::Response::builder().status(200).body(None)?)
}
let mut router = Router::default();
router.get("/h1/:param", h1);
let req = make_request(http_types::Method::GET, "/h1/");
let res = router.handle(req).unwrap();
assert_eq!(res.status(), http_types::StatusCode::NOT_FOUND);
}
#[test]
fn test_multi_param() {
fn multiply(_req: Request, params: Params) -> Result<Response> {
let x: i64 = params.get("x").unwrap().parse()?;
let y: i64 = params.get("y").unwrap().parse()?;
Ok(http_types::Response::builder()
.status(http_types::StatusCode::OK)
.body(Some(format!("{result}", result = x * y).into()))?)
}
let mut router = Router::default();
router.get("/multiply/:x/:y", multiply);
let req = make_request(http_types::Method::GET, "/multiply/2/4");
let res = router.handle(req).unwrap();
assert_eq!(res.into_body().unwrap(), "8".to_string());
}
#[test]
fn test_param() {
let mut router = Router::default();
router.get("/:x", echo_param);
let req = make_request(http_types::Method::GET, "/y");
let res = router.handle(req).unwrap();
assert_eq!(res.into_body().unwrap(), "y".to_string());
}
#[test]
fn test_wildcard() {
fn echo_wildcard(req: Request, params: Params) -> Result<Response> {
match params.wildcard() {
Some(path) => Ok(http_types::Response::builder()
.status(http_types::StatusCode::OK)
.body(Some(path.to_string().into()))?),
None => not_found(req, params),
}
}
let mut router = Router::default();
router.get("/*", echo_wildcard);
let req = make_request(http_types::Method::GET, "/foo/bar");
let res = router.handle(req).unwrap();
assert_eq!(res.status(), http_types::StatusCode::OK);
assert_eq!(res.into_body().unwrap(), "foo/bar".to_string());
}
#[test]
fn test_wildcard_last_segment() {
let mut router = Router::default();
router.get("/:x/*", echo_param);
let req = make_request(http_types::Method::GET, "/foo/bar");
let res = router.handle(req).unwrap();
assert_eq!(res.into_body().unwrap(), "foo".to_string());
}
#[test]
fn test_ambiguous_wildcard_vs_star() {
fn h1(_req: Request, _params: Params) -> Result<Response> {
Ok(http_types::Response::builder()
.status(http_types::StatusCode::OK)
.body(Some("one/two".into()))?)
}
fn h2(_req: Request, _params: Params) -> Result<Response> {
Ok(http_types::Response::builder()
.status(http_types::StatusCode::OK)
.body(Some("posts/*".into()))?)
}
let mut router = Router::default();
router.get("/:one/:two", h1);
router.get("/posts/*", h2);
let req = make_request(http_types::Method::GET, "/posts/2");
let res = router.handle(req).unwrap();
assert_eq!(res.into_body().unwrap(), "posts/*".to_string());
}
}

View File

@ -28,23 +28,27 @@ pub mod http {
use anyhow::Result;
/// The Spin HTTP request.
pub type Request = http::Request<Option<bytes::Bytes>>;
pub type Request = http_types::Request<Option<bytes::Bytes>>;
/// The Spin HTTP response.
pub type Response = http::Response<Option<bytes::Bytes>>;
pub type Response = http_types::Response<Option<bytes::Bytes>>;
pub use crate::outbound_http::send_request as send;
/// Exports HTTP Router items.
pub use router::*;
mod router;
/// Helper function to return a 404 Not Found response.
pub fn not_found() -> Result<Response> {
Ok(http::Response::builder()
Ok(http_types::Response::builder()
.status(404)
.body(Some("Not Found".into()))?)
}
/// Helper function to return a 500 Internal Server Error response.
pub fn internal_server_error() -> Result<Response> {
Ok(http::Response::builder()
Ok(http_types::Response::builder()
.status(500)
.body(Some("Internal Server Error".into()))?)
}

View File

@ -1,4 +1,4 @@
use http::{header::HeaderName, HeaderValue};
use http_types::{header::HeaderName, HeaderValue};
use super::http::{Request, Response};
@ -42,7 +42,7 @@ pub fn send_request(req: Request) -> Result<Response> {
body,
} = wasi_outbound_http::request(out_req)?;
let resp_builder = http::response::Builder::new().status(status);
let resp_builder = http_types::response::Builder::new().status(status);
let resp_builder = headers
.into_iter()
.flatten()
@ -64,11 +64,11 @@ fn try_header_to_strs<'k, 'v>(
))
}
impl TryFrom<http::Method> for wasi_outbound_http::Method {
impl TryFrom<http_types::Method> for wasi_outbound_http::Method {
type Error = OutboundHttpError;
fn try_from(method: http::Method) -> Result<Self> {
use http::Method;
fn try_from(method: http_types::Method) -> Result<Self> {
use http_types::Method;
use wasi_outbound_http::Method::*;
Ok(match method {
Method::GET => Get,

View File

@ -19,6 +19,12 @@ dependencies = [
"syn",
]
[[package]]
name = "autocfg"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "bitflags"
version = "1.3.2"
@ -130,6 +136,36 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "routefinder"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f8f99b10dedd317514253dda1fa7c14e344aac96e1f78149a64879ce282aca"
dependencies = [
"smartcow",
"smartstring",
]
[[package]]
name = "smartcow"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "656fcb1c1fca8c4655372134ce87d8afdf5ec5949ebabe8d314be0141d8b5da2"
dependencies = [
"smartstring",
]
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"static_assertions",
"version_check",
]
[[package]]
name = "spin-macro"
version = "0.1.0"
@ -151,11 +187,18 @@ dependencies = [
"bytes",
"form_urlencoded",
"http",
"routefinder",
"spin-macro",
"thiserror",
"wit-bindgen-rust",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "syn"
version = "1.0.85"

View File

@ -19,6 +19,12 @@ dependencies = [
"syn",
]
[[package]]
name = "autocfg"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "bitflags"
version = "1.3.2"
@ -126,6 +132,16 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "routefinder"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f8f99b10dedd317514253dda1fa7c14e344aac96e1f78149a64879ce282aca"
dependencies = [
"smartcow",
"smartstring",
]
[[package]]
name = "simple-spin-rust"
version = "0.1.0"
@ -137,6 +153,26 @@ dependencies = [
"wit-bindgen-rust",
]
[[package]]
name = "smartcow"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "656fcb1c1fca8c4655372134ce87d8afdf5ec5949ebabe8d314be0141d8b5da2"
dependencies = [
"smartstring",
]
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"static_assertions",
"version_check",
]
[[package]]
name = "spin-macro"
version = "0.1.0"
@ -158,11 +194,18 @@ dependencies = [
"bytes",
"form_urlencoded",
"http",
"routefinder",
"spin-macro",
"thiserror",
"wit-bindgen-rust",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "syn"
version = "1.0.85"