From 4808351452e0db100aa130b17f27203a97a60985 Mon Sep 17 00:00:00 2001 From: Stuart Harris Date: Thu, 15 Aug 2024 12:49:12 +0100 Subject: [PATCH] add router --- platform-wasmcloud/restart.fish | 4 + wasm-components/rust/Cargo.lock | 43 ++++ .../rust/http-controller/Cargo.toml | 1 + .../rust/http-controller/src/http.rs | 31 +-- .../rust/http-controller/src/lib.rs | 213 ++++++++---------- 5 files changed, 151 insertions(+), 141 deletions(-) create mode 100755 platform-wasmcloud/restart.fish diff --git a/platform-wasmcloud/restart.fish b/platform-wasmcloud/restart.fish new file mode 100755 index 0000000..30bfc68 --- /dev/null +++ b/platform-wasmcloud/restart.fish @@ -0,0 +1,4 @@ +#!/usr/bin/env fish + +./stop.fish +./start.fish diff --git a/wasm-components/rust/Cargo.lock b/wasm-components/rust/Cargo.lock index f84cedb..b107a6d 100644 --- a/wasm-components/rust/Cargo.lock +++ b/wasm-components/rust/Cargo.lock @@ -20,6 +20,12 @@ version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + [[package]] name = "bitflags" version = "2.6.0" @@ -99,6 +105,7 @@ dependencies = [ "anyhow", "common", "form_urlencoded", + "routefinder", "serde", "serde_json", "wit-bindgen", @@ -241,6 +248,16 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "routefinder" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0971d3c8943a6267d6bd0d782fdc4afa7593e7381a92a3df950ff58897e066b5" +dependencies = [ + "smartcow", + "smartstring", +] + [[package]] name = "ryu" version = "1.0.18" @@ -291,6 +308,26 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[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 = "spdx" version = "0.10.6" @@ -300,6 +337,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "syn" version = "2.0.72" diff --git a/wasm-components/rust/http-controller/Cargo.toml b/wasm-components/rust/http-controller/Cargo.toml index 90a59d7..b758d46 100644 --- a/wasm-components/rust/http-controller/Cargo.toml +++ b/wasm-components/rust/http-controller/Cargo.toml @@ -10,6 +10,7 @@ crate-type = ["cdylib"] anyhow = "1.0.40" common = { path = "../common" } form_urlencoded = "1.2.1" +routefinder = "0.5.4" serde.workspace = true serde_json.workspace = true wit-bindgen.workspace = true diff --git a/wasm-components/rust/http-controller/src/http.rs b/wasm-components/rust/http-controller/src/http.rs index 75ce2a7..778ab56 100644 --- a/wasm-components/rust/http-controller/src/http.rs +++ b/wasm-components/rust/http-controller/src/http.rs @@ -32,10 +32,6 @@ impl ResponseOutparam { } impl IncomingRequest { - pub fn parts(&self) -> (Vec, Option) { - parse_path_and_query(self.path_with_query().unwrap()) - } - pub fn read_body(self) -> Result> { let incoming_req_body = self .consume() @@ -58,22 +54,15 @@ impl IncomingRequest { } } -fn parse_path_and_query(path_and_query: String) -> (Vec, Option) { +pub fn path_and_query(path_with_query: &str) -> (&str, Option<&str>) { let (path, query) = - path_and_query.split_at(path_and_query.find('?').unwrap_or(path_and_query.len())); + path_with_query.split_at(path_with_query.find('?').unwrap_or(path_with_query.len())); let query = if query.is_empty() { None } else { - Some(query.trim_start_matches("?").to_string()) + Some(query.trim_start_matches("?")) }; - - let path_parts: Vec = path - .strip_prefix('/') - .map(|remainder| remainder.split('/')) - .map(|c| c.map(|s| s.to_string()).collect()) - .unwrap_or_default(); - - (path_parts, query) + (path, query) } #[cfg(test)] @@ -82,11 +71,11 @@ mod test { #[test] fn test_parse_path_and_query() { - let path = "/1/products?skus=sku1,sku2"; - - let (parts, query) = parse_path_and_query(path.to_string()); - - assert_eq!(parts, ["1", "products"]); - assert_eq!(query, Some("skus=sku1,sku2".to_string())); + assert_eq!( + path_and_query("/1/products?skus=sku1,sku2"), + ("/1/products", Some("skus=sku1,sku2")) + ); + assert_eq!(path_and_query("/products"), ("/products", None)); + assert_eq!(path_and_query(""), ("", None)); } } diff --git a/wasm-components/rust/http-controller/src/lib.rs b/wasm-components/rust/http-controller/src/lib.rs index d8e654c..6df5bd8 100644 --- a/wasm-components/rust/http-controller/src/lib.rs +++ b/wasm-components/rust/http-controller/src/lib.rs @@ -18,6 +18,8 @@ wit_bindgen::generate!({ generate_all, }); +use anyhow::{anyhow, Result}; +use routefinder::Router; use serde_json::json; use common::{ @@ -44,63 +46,76 @@ export!(Component); impl Guest for Component { fn handle(request: IncomingRequest, response_out: ResponseOutparam) { - let method = request.method(); - let path_and_query = request.path_with_query().unwrap(); - let (path_parts, query) = request.parts(); - - log( - Level::Info, - "http-controller", - format!("Received {:?} request at {}", method, path_and_query).as_str(), - ); - - match path_parts - .iter() - .map(|s| s.as_ref()) - .collect::>() - .as_slice() - { - ["products", path_parts @ ..] => { - Routes::products(path_parts, query.as_deref(), request, response_out) - } - ["data-init", path_parts @ ..] => { - Routes::data_init(path_parts, query.as_deref(), request, response_out) + match handle(request) { + Ok((status_code, body)) => { + response_out.complete_response(status_code, body.as_bytes()); } - ["inventory", path_parts @ ..] => { - Routes::inventory(path_parts, query.as_deref(), request, response_out) + Err(e) => { + log( + Level::Error, + "http-controller", + format!("Error: {:?}", e).as_str(), + ); + response_out.complete_response(500, b"Internal Server Error"); } - ["orders", path_parts @ ..] => { - Routes::orders(path_parts, query.as_deref(), request, response_out) - } - _ => response_out.complete_response(404, b"404 Not Found\n"), - } + }; + } +} - log( - Level::Info, - "http-controller", - format!("Completed {:?} request at {}", method, path_and_query).as_str(), - ); +fn handle(request: IncomingRequest) -> Result<(StatusCode, String)> { + let method = request.method(); + let path_with_query = request.path_with_query().unwrap_or_default(); + let (path, query) = http::path_and_query(&path_with_query); + + log( + Level::Info, + "http-controller", + format!("Received {:?} request at {}", method, path_with_query).as_str(), + ); + + let mut router = Router::new(); + + router + .add("/data-init/:action", Handlers::DataInit) + .map_err(|e| anyhow!("adding route: {}", e))?; + router + .add("/inventory/*", Handlers::Inventory) + .map_err(|e| anyhow!("adding route: {}", e))?; + router + .add("/orders", Handlers::Orders) + .map_err(|e| anyhow!("adding route: {}", e))?; + router + .add("/products", Handlers::Products) + .map_err(|e| anyhow!("adding route: {}", e))?; + + let Some(m) = router.best_match(path) else { + return Ok((404, "404 Not Found\n".to_string())); + }; + + match m.handler() { + Handlers::DataInit => { + let captures = m.captures(); + let action = captures.get("action").unwrap(); + Handlers::data_init(action, request) + } + Handlers::Inventory => Handlers::inventory(query, request), + Handlers::Orders => Handlers::orders(request), + Handlers::Products => Handlers::products(request), } } -struct Routes; +enum Handlers { + DataInit, + Inventory, + Orders, + Products, +} // TODO: improve error handling everywhere // TODO: refactor this into less of a mess -impl Routes { - fn products( - path_parts: &[&str], - _query: Option<&str>, - request: IncomingRequest, - response_out: ResponseOutparam, - ) { - let method = request.method(); - - if !path_parts.is_empty() { - return response_out.complete_response(404, b"404 Not Found\n"); - } - - match method { +impl Handlers { + fn products(request: IncomingRequest) -> Result<(StatusCode, String)> { + match request.method() { Method::Get => { let products = list_products().expect("HTTP-CONTROLLER-PRODUCTS-GET: failed to list products"); @@ -108,9 +123,7 @@ impl Routes { .iter() .map(|product| ProductData::from(product.clone())) .collect::>(); - let products_json = json!(product_data).to_string(); - - response_out.complete_response(200, products_json.as_bytes()); + Ok((200, json!(product_data).to_string())) } Method::Post => { let body = request @@ -119,81 +132,59 @@ impl Routes { let product: Product = serde_json::from_slice::(&body).unwrap().into(); create_product(&product) .expect("HTTP-CONTROLLER-PRODUCTS-POST: failed to create product"); - response_out.complete_response(201, "Created".as_bytes()); + Ok((201, "201 Created\n".to_string())) } - _ => response_out.complete_response(405, b"405 Method Not Allowed\n"), + _ => Ok((405, "405 Method Not Allowed\n".to_string())), } } - fn data_init( - path_parts: &[&str], - _query: Option<&str>, - request: IncomingRequest, - response_out: ResponseOutparam, - ) { - let method = request.method(); - - if path_parts.len() > 1 { - return response_out.complete_response(404, b"404 Not Found\n"); - } - - match method { - Method::Get => match path_parts { - ["all"] => { + fn data_init(action: &str, request: IncomingRequest) -> Result<(StatusCode, String)> { + match request.method() { + Method::Get => match action { + "all" => { init_all() .expect("HTTP-CONTROLLER-DATA-INIT-ALL failed to initialize products"); - response_out.complete_response( + Ok(( 200, - "Products, inventory and orders schema initialized".as_bytes(), - ) + "Products, inventory and orders schema initialized\n".to_string(), + )) } - ["products"] => { + "products" => { init_products().expect( "HTTP-CONTROLLER-DATA-INIT-PRODUCTS: failed to initialize products", ); - response_out.complete_response(200, "Products initialized".as_bytes()) + Ok((200, "Products initialized\n".to_string())) } - ["inventory"] => { + "inventory" => { init_inventory().expect( "HTTP-CONTROLLER-DATA-INIT-INVENTORY: failed to initialize inventory", ); - response_out.complete_response(200, "Inventory initialized".as_bytes()) + Ok((200, "Inventory initialized\n".to_string())) } - ["orders"] => { + "orders" => { init_orders().expect( "HTTP-CONTROLLER-DATA-INIT-ORDERS: failed to initialize orders schema", ); - response_out.complete_response(200, "Orders schema initialized".as_bytes()) + Ok((200, "Orders schema initialized\n".to_string())) } - _ => response_out.complete_response(404, b"404 Not Found\n"), + _ => Ok((404, "404 Not Found\n".to_string())), }, - _ => response_out.complete_response(405, b"405 Method Not Allowed\n"), + _ => Ok((405, "405 Method Not Allowed\n".to_string())), } } - fn inventory( - path_parts: &[&str], - query: Option<&str>, - request: IncomingRequest, - response_out: ResponseOutparam, - ) { - if !path_parts.is_empty() { - return response_out.complete_response(404, b"404 Not Found\n"); - } - + fn inventory(query: Option<&str>, request: IncomingRequest) -> Result<(StatusCode, String)> { if query.is_none() { - return response_out.complete_response(400, b"400 Bad Request\n"); + return Ok((400, "400 Bad Request\n".to_string())); } if let Some(value) = query { if !value.contains("skus=") { - return response_out.complete_response(400, b"400 Bad Request\n"); + return Ok((400, "400 Bad Request\n".to_string())); } } - let method = request.method(); - - match method { + match request.method() { Method::Get => { let query_str = query.unwrap(); let mut query_pairs = form_urlencoded::parse(query_str.as_bytes()); @@ -210,27 +201,14 @@ impl Routes { .map(|entry| AvailabilityData::from(entry.clone())) .collect(); - let inventory_json = json!(inventory_data).to_string(); - - response_out.complete_response(200, inventory_json.as_bytes()) + Ok((200, json!(inventory_data).to_string())) } - _ => response_out.complete_response(405, b"405 Method Not Allowed\n"), + _ => Ok((405, "405 Method Not Allowed\n".to_string())), } } - fn orders( - path_parts: &[&str], - _query: Option<&str>, - request: IncomingRequest, - response_out: ResponseOutparam, - ) { - if !path_parts.is_empty() { - return response_out.complete_response(404, b"404 Not Found\n"); - } - - let method = request.method(); - - match method { + fn orders(request: IncomingRequest) -> Result<(StatusCode, String)> { + match request.method() { Method::Get => { let orders = get_orders().expect("HTTP-CONTROLLER-ORDERS-GET: failed to get orders"); @@ -238,9 +216,7 @@ impl Routes { .iter() .map(|order| OrderData::from(order.clone())) .collect(); - let orders_json = json!(order_data).to_string(); - - response_out.complete_response(200, orders_json.as_bytes()) + Ok((200, json!(order_data).to_string())) } Method::Post => { let body = request @@ -254,17 +230,14 @@ impl Routes { let create_response = create_order(line_items.as_slice()); match create_response { - Ok(_) => response_out.complete_response(201, "Created".as_bytes()), + Ok(_) => Ok((201, "201 Created\n".to_string())), Err(e) => { let OrderError::Internal(msg) = e; - response_out.complete_response( - 500, - format!("Unable to place order: {}", msg).as_bytes(), - ) + Ok((500, format!("Unable to place order: {}\n", msg))) } } } - _ => response_out.complete_response(405, b"405 Method Not Allowed\n"), + _ => Ok((405, "405 Method Not Allowed\n".to_string())), } } }