diff --git a/README.md b/README.md index a65f2fc..9cd5882 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ application with a Rust backend and a Postgres database. Use with Docker Dev Environments - [`Spring / PostgreSQL`](spring-postgres) - Sample Java application with Spring framework and a Postgres database. Use with Docker Dev Environments +- [`WasmEdge / MySQL / Nginx`](wasmedge-mysql-nginx) - Sample WebAssembly-based microservice +with WasmEdge Runtime, a MySQL / MariaDB database, and an optional Nginx web server for hosting static web UI files. Compatible with Docker+wasm ## Single service samples diff --git a/wasmedge-mysql-nginx/.docker/docker-compose.yml b/wasmedge-mysql-nginx/.docker/docker-compose.yml new file mode 100644 index 0000000..88e9181 --- /dev/null +++ b/wasmedge-mysql-nginx/.docker/docker-compose.yml @@ -0,0 +1,26 @@ +services: + frontend: + image: nginx:alpine + ports: + - 8090:80 + volumes: + - ./frontend:/usr/share/nginx/html + + backend: + image: demo-microservice + build: + context: backend/ + platforms: + - wasi/wasm32 + ports: + - 8080:8080 + environment: + DATABASE_URL: mysql://root:whalehello@db:3306/mysql + RUST_BACKTRACE: full + restart: unless-stopped + runtime: io.containerd.wasmedge.v1 + + db: + image: mariadb:10.9 + environment: + MYSQL_ROOT_PASSWORD: whalehello diff --git a/wasmedge-mysql-nginx/README.md b/wasmedge-mysql-nginx/README.md new file mode 100644 index 0000000..5746dbd --- /dev/null +++ b/wasmedge-mysql-nginx/README.md @@ -0,0 +1,120 @@ +# Compose sample application + +we demonstrate a microservice written in Rust, and connected to a MySQL database (mariaDB). It supports CRUD operations on a database table via a web interface. The microservice is compiled into WebAssembly (Wasm) and runs in the WasmEdge Runtime, which is a secure and lightweight alternative to natively compiled Rust apps in Linux containers. Checkout [this article](https://blog.logrocket.com/rust-microservices-server-side-webassembly/) or [this video](https://www.youtube.com/watch?v=VSqMPFr7SEs) to learn how the Rust code in this microservice works. + +## Use with Docker Development Environments + +You will need a version of Docker Desktop or Docker CLI with Wasm support. + +* [Install Docker Desktop + Wasm (Beta)](https://docs.docker.com/desktop/wasm/) +* [Install Docker CLI + Wasm](https://github.com/chris-crone/wasm-day-na-22/tree/main/server) + +## WasmEdge server with Nginx proxy and MySQL database + +Project structure: + +``` +. ++-- compose.yml +|-- backend + +-- Dockerfile + |-- Cargo.toml + |-- src + +-- main.rs +|-- frontend + +-- index.html + |-- js + +-- app.js +|-- db + +-- orders.json + |-- update_order.json +``` + +The [compose.yml](compose.yml) is as follows. + +```yaml +services: + frontend: + image: nginx:alpine + ports: + - 8090:80 + volumes: + - ./frontend:/usr/share/nginx/html + + backend: + image: demo-microservice + build: + context: backend/ + platforms: + - wasi/wasm32 + ports: + - 8080:8080 + environment: + DATABASE_URL: mysql://root:whalehello@db:3306/mysql + RUST_BACKTRACE: full + restart: unless-stopped + runtime: io.containerd.wasmedge.v1 + + db: + image: mariadb:10.9 + environment: + MYSQL_ROOT_PASSWORD: whalehello +``` + +The compose file defines an application with three services `frontend`, `backend` and `db`. The `frontend` is a simple Nginx server that hosts static web pages that access the `backend` web service, in the WasmEdge container, via HTTP port 8080. When deploying the application, docker compose maps port 8090 of the `frontend` service container to port 80 of the host as specified in the file. Make sure ports 80 and 8080 on the host is not already being in use. + +## Deploy with docker compose + +```bash +$ docker compose up -d +... + ⠿ Network wasmedge-mysql-nginx_default Created + ⠿ Container wasmedge-mysql-nginx-db-1 Created + ⠿ Container wasmedge-mysql-nginx-frontend-1 Created + ⠿ Container wasmedge-mysql-nginx-backend-1 Created +``` + +## Expected result + +```bash +$ docker compose ps +NAME COMMAND SERVICE STATUS PORTS +wasmedge-mysql-nginx-backend-1 "order_demo_service.…" backend running 0.0.0.0:8080->8080/tcp, :::8080->8080/tcp +wasmedge-mysql-nginx-db-1 "docker-entrypoint.s…" db running 3306/tcp +wasmedge-mysql-nginx-frontend-1 "/docker-entrypoint.…" frontend running 0.0.0.0:8090->80/tcp, :::8090->80/tcp +``` + +After the application starts, navigate to `http://localhost:80` in your web browser to interact with the `backend` WasmEdge server and database through HTML and JavaScript. +Alternatively, you can use the `curl` command to interact with the WasmEdge web service (i.e., the `backend` service). + +When the WasmEdge web service receives a GET request to the `/init` endpoint, it would initialize the database with the `orders` table. + +```bash +curl http://localhost:8080/init +``` + +When the WasmEdge web service receives a POST request to the `/create_order` endpoint, it would extract the JSON data from the POST body and insert an `Order` record into the database table. +For multiple records, use the `/create_orders` endpoint and POST a JSON array of `Order` objects. + +```bash +curl http://localhost:8080/create_orders -X POST -d @db/orders.json +``` + +When the WasmEdge web service receives a GET request to the `/orders` endpoint, it would get all rows from the `orders` table and return the result set in a JSON array in the HTTP response. + +```bash +curl http://localhost:8080/orders +``` + +When the WasmEdge web service receives a POST request to the `/update_order` endpoint, it would extract the JSON data from the POST body and update the `Order` record in the database table that matches the `order_id` in the input data. + +```bash +curl http://localhost:8080/update_order -X POST -d @db/update_order.json +``` + +When the WasmEdge web service receives a GET request to the `/delete_order` endpoint, it would delete the row in the `orders` table that matches the `id` GET parameter. + +```bash +curl http://localhost:8080/delete_order?id=2 +``` + diff --git a/wasmedge-mysql-nginx/backend/Cargo.toml b/wasmedge-mysql-nginx/backend/Cargo.toml new file mode 100644 index 0000000..406cf3f --- /dev/null +++ b/wasmedge-mysql-nginx/backend/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "order_demo_service" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0" +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +url = "2.3" +mysql_async_wasi = "0.30" +hyper_wasi = { version = "0.15", features = ["full"] } +tokio_wasi = { version = "1", features = ["io-util", "fs", "net", "time", "rt", "macros"] } diff --git a/wasmedge-mysql-nginx/backend/Dockerfile b/wasmedge-mysql-nginx/backend/Dockerfile new file mode 100644 index 0000000..17652e9 --- /dev/null +++ b/wasmedge-mysql-nginx/backend/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 + +FROM --platform=$BUILDPLATFORM rust:1.64 AS buildbase +WORKDIR /src +RUN < String { + if let Ok(url) = std::env::var("DATABASE_URL") { + let opts = Opts::from_url(&url).expect("DATABASE_URL invalid"); + if opts + .db_name() + .expect("a database name is required") + .is_empty() + { + panic!("database name is empty"); + } + url + } else { + "mysql://root:pass@127.0.0.1:3306/mysql".into() + } +} + +#[derive(Serialize, Deserialize, Debug)] +struct Order { + order_id: i32, + product_id: i32, + quantity: i32, + amount: f32, + shipping: f32, + tax: f32, + shipping_address: String, +} + +impl Order { + fn new( + order_id: i32, + product_id: i32, + quantity: i32, + amount: f32, + shipping: f32, + tax: f32, + shipping_address: String, + ) -> Self { + Self { + order_id, + product_id, + quantity, + amount, + shipping, + tax, + shipping_address, + } + } +} + +async fn handle_request(req: Request, pool: Pool) -> Result, anyhow::Error> { + match (req.method(), req.uri().path()) { + (&Method::GET, "/") => Ok(Response::new(Body::from( + "The valid endpoints are /init /create_order /create_orders /update_order /orders /delete_order", + ))), + + // Simply echo the body back to the client. + (&Method::POST, "/echo") => Ok(Response::new(req.into_body())), + + // CORS OPTIONS + (&Method::OPTIONS, "/init") => Ok(response_build(&String::from(""))), + (&Method::OPTIONS, "/create_order") => Ok(response_build(&String::from(""))), + (&Method::OPTIONS, "/create_orders") => Ok(response_build(&String::from(""))), + (&Method::OPTIONS, "/update_order") => Ok(response_build(&String::from(""))), + (&Method::OPTIONS, "/delete_order") => Ok(response_build(&String::from(""))), + (&Method::OPTIONS, "/orders") => Ok(response_build(&String::from(""))), + + (&Method::GET, "/init") => { + let mut conn = pool.get_conn().await.unwrap(); + "DROP TABLE IF EXISTS orders;".ignore(&mut conn).await?; + "CREATE TABLE orders (order_id INT, product_id INT, quantity INT, amount FLOAT, shipping FLOAT, tax FLOAT, shipping_address VARCHAR(20));".ignore(&mut conn).await?; + drop(conn); + Ok(response_build("{\"status\":true}")) + } + + (&Method::POST, "/create_order") => { + let mut conn = pool.get_conn().await.unwrap(); + + let byte_stream = hyper::body::to_bytes(req).await?; + let order: Order = serde_json::from_slice(&byte_stream).unwrap(); + + "INSERT INTO orders (order_id, product_id, quantity, amount, shipping, tax, shipping_address) VALUES (:order_id, :product_id, :quantity, :amount, :shipping, :tax, :shipping_address)" + .with(params! { + "order_id" => order.order_id, + "product_id" => order.product_id, + "quantity" => order.quantity, + "amount" => order.amount, + "shipping" => order.shipping, + "tax" => order.tax, + "shipping_address" => &order.shipping_address, + }) + .ignore(&mut conn) + .await?; + + drop(conn); + Ok(response_build("{\"status\":true}")) + // Ok(Response::new(Body::from("{\"status\":true}"))) + } + + (&Method::POST, "/create_orders") => { + let mut conn = pool.get_conn().await.unwrap(); + + let byte_stream = hyper::body::to_bytes(req).await?; + let orders: Vec = serde_json::from_slice(&byte_stream).unwrap(); + + "INSERT INTO orders (order_id, product_id, quantity, amount, shipping, tax, shipping_address) VALUES (:order_id, :product_id, :quantity, :amount, :shipping, :tax, :shipping_address)" + .with(orders.iter().map(|order| { + params! { + "order_id" => order.order_id, + "product_id" => order.product_id, + "quantity" => order.quantity, + "amount" => order.amount, + "shipping" => order.shipping, + "tax" => order.tax, + "shipping_address" => &order.shipping_address, + } + })) + .batch(&mut conn) + .await?; + + drop(conn); + Ok(response_build("{\"status\":true}")) + // Ok(Response::new(Body::from("{\"status\":true}"))) + } + + (&Method::POST, "/update_order") => { + let mut conn = pool.get_conn().await.unwrap(); + + let byte_stream = hyper::body::to_bytes(req).await?; + let order: Order = serde_json::from_slice(&byte_stream).unwrap(); + + "UPDATE orders SET product_id=:product_id, quantity=:quantity, amount=:amount, shipping=:shipping, tax=:tax, shipping_address=:shipping_address WHERE order_id=:order_id" + .with(params! { + "product_id" => order.product_id, + "quantity" => order.quantity, + "amount" => order.amount, + "shipping" => order.shipping, + "tax" => order.tax, + "shipping_address" => &order.shipping_address, + "order_id" => order.order_id, + }) + .ignore(&mut conn) + .await?; + + drop(conn); + Ok(response_build("{\"status\":true}")) + // Ok(Response::new(Body::from("{\"status\":true}"))) + } + + (&Method::GET, "/orders") => { + let mut conn = pool.get_conn().await.unwrap(); + + let orders = "SELECT * FROM orders" + .with(()) + .map(&mut conn, |(order_id, product_id, quantity, amount, shipping, tax, shipping_address)| { + Order::new( + order_id, + product_id, + quantity, + amount, + shipping, + tax, + shipping_address, + )}, + ).await?; + + drop(conn); + Ok(response_build(serde_json::to_string(&orders)?.as_str())) + // Ok(Response::new(Body::from(serde_json::to_string(&orders)?))) + } + + (&Method::GET, "/delete_order") => { + let mut conn = pool.get_conn().await.unwrap(); + + let params: HashMap = req.uri().query().map(|v| { + url::form_urlencoded::parse(v.as_bytes()).into_owned().collect() + }).unwrap_or_else(HashMap::new); + let order_id = params.get("id"); + + "DELETE FROM orders WHERE order_id=:order_id" + .with(params! { "order_id" => order_id, }) + .ignore(&mut conn) + .await?; + + drop(conn); + Ok(response_build("{\"status\":true}")) + // Ok(Response::new(Body::from("{\"status\":true}"))) + } + + // Return the 404 Not Found for other routes. + _ => { + let mut not_found = Response::default(); + *not_found.status_mut() = StatusCode::NOT_FOUND; + Ok(not_found) + } + } +} + +// CORS headers +fn response_build(body: &str) -> Response { + Response::builder() + .header("Access-Control-Allow-Origin", "*") + .header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + .header("Access-Control-Allow-Headers", "api,Keep-Alive,User-Agent,Content-Type") + .body(Body::from(body.to_owned())) + .unwrap() +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let opts = Opts::from_url(&*get_url()).unwrap(); + let builder = OptsBuilder::from_opts(opts); + // The connection pool will have a min of 5 and max of 10 connections. + let constraints = PoolConstraints::new(5, 10).unwrap(); + let pool_opts = PoolOpts::default().with_constraints(constraints); + let pool = Pool::new(builder.pool_opts(pool_opts)); + + let addr = SocketAddr::from(([0, 0, 0, 0], 8080)); + let make_svc = make_service_fn(|_| { + let pool = pool.clone(); + async move { + Ok::<_, Infallible>(service_fn(move |req| { + let pool = pool.clone(); + handle_request(req, pool) + })) + } + }); + let server = Server::bind(&addr).serve(make_svc); + if let Err(e) = server.await { + eprintln!("server error: {}", e); + } + Ok(()) + + /* + let listener = TcpListener::bind(addr).await?; + println!("Listening on http://{}", addr); + loop { + let (stream, _) = listener.accept().await?; + + tokio::task::spawn(async move { + if let Err(err) = Http::new().serve_connection(stream, service_fn(handle_request)).await { + println!("Error serving connection: {:?}", err); + } + }); + } + */ +} diff --git a/wasmedge-mysql-nginx/compose.yml b/wasmedge-mysql-nginx/compose.yml new file mode 100644 index 0000000..88e9181 --- /dev/null +++ b/wasmedge-mysql-nginx/compose.yml @@ -0,0 +1,26 @@ +services: + frontend: + image: nginx:alpine + ports: + - 8090:80 + volumes: + - ./frontend:/usr/share/nginx/html + + backend: + image: demo-microservice + build: + context: backend/ + platforms: + - wasi/wasm32 + ports: + - 8080:8080 + environment: + DATABASE_URL: mysql://root:whalehello@db:3306/mysql + RUST_BACKTRACE: full + restart: unless-stopped + runtime: io.containerd.wasmedge.v1 + + db: + image: mariadb:10.9 + environment: + MYSQL_ROOT_PASSWORD: whalehello diff --git a/wasmedge-mysql-nginx/db/orders.json b/wasmedge-mysql-nginx/db/orders.json new file mode 100644 index 0000000..9f2d21e --- /dev/null +++ b/wasmedge-mysql-nginx/db/orders.json @@ -0,0 +1,47 @@ +[ + { + "order_id": 1, + "product_id": 12, + "quantity": 2, + "amount": 56.0, + "shipping": 15.0, + "tax": 2.0, + "shipping_address": "Mataderos 2312" + }, + { + "order_id": 2, + "product_id": 15, + "quantity": 3, + "amount": 256.0, + "shipping": 30.0, + "tax": 16.0, + "shipping_address": "1234 NW Bobcat" + }, + { + "order_id": 3, + "product_id": 11, + "quantity": 5, + "amount": 536.0, + "shipping": 50.0, + "tax": 24.0, + "shipping_address": "20 Havelock" + }, + { + "order_id": 4, + "product_id": 8, + "quantity": 8, + "amount": 126.0, + "shipping": 20.0, + "tax": 12.0, + "shipping_address": "224 Pandan Loop" + }, + { + "order_id": 5, + "product_id": 24, + "quantity": 1, + "amount": 46.0, + "shipping": 10.0, + "tax": 2.0, + "shipping_address": "No.10 Jalan Besar" + } +] diff --git a/wasmedge-mysql-nginx/db/update_order.json b/wasmedge-mysql-nginx/db/update_order.json new file mode 100644 index 0000000..4f39358 --- /dev/null +++ b/wasmedge-mysql-nginx/db/update_order.json @@ -0,0 +1,9 @@ +{ + "order_id": 3, + "product_id": 12, + "quantity": 2, + "amount": 56.0, + "shipping": 15.0, + "tax": 2.0, + "shipping_address": "123 Main Street" +} diff --git a/wasmedge-mysql-nginx/frontend/index.html b/wasmedge-mysql-nginx/frontend/index.html new file mode 100644 index 0000000..d07c384 --- /dev/null +++ b/wasmedge-mysql-nginx/frontend/index.html @@ -0,0 +1,102 @@ + + + + Demo App + + + + +
+
+

Loading...

+
+ +
+

Welcome to the Demo!

+

This application is served using nginx for the website, Wasm for the backend, and MariaDB for the database.

+ +
+ There are currently no orders to display! +
+ + + + + + + + + + + + + + + +
IdProduct IdQuantityAmountShippingTaxAddress
+ +
+ +
+
+
+
+

+ +

+ +
+
+
+ + +
The ID of the order
+
+
+ + +
The ID of the product
+
+
+ + +
How many of the product?
+
+
+ + +
The total amount
+
+
+ + +
The total amount of tax
+
+
+ + +
The total amount for shipping
+
+
+ + +
Where to send the order
+
+ + +
+
+
+
+
+
+
+ +
+ + + + + \ No newline at end of file diff --git a/wasmedge-mysql-nginx/frontend/js/app.js b/wasmedge-mysql-nginx/frontend/js/app.js new file mode 100644 index 0000000..abaeb4b --- /dev/null +++ b/wasmedge-mysql-nginx/frontend/js/app.js @@ -0,0 +1,135 @@ +(function() { + let orders = null; + const appLoadingEle = document.getElementById("app-loading-display"); + const orderWrapperEle = document.getElementById("order-display"); + const orderEmptyTextEle = document.getElementById("order-empty-text"); + const orderTableEle = document.getElementById("order-table"); + const orderTableBodyEle = document.querySelector("#order-table tbody"); + const addOrderEle = document.getElementById("add-order-wrapper"); + const addOrderForm = document.getElementById("add-order-form"); + + const orderIdField = document.getElementById("order-id"); + const productIdField = document.getElementById("product-id"); + const quantityField = document.getElementById("quantity"); + const amountField = document.getElementById("amount"); + const taxField = document.getElementById("tax"); + const shippingField = document.getElementById("shippingAmount"); + const shippingAddressField = document.getElementById("shippingAddress"); + + function fetchOrders() { + fetch("http://localhost:8080/orders") + .then(r => r.json()) + .then(r => orders = r) + .then(renderOrders) + .catch((e) => { + init(); + }); + } + + function init() { + fetch("http://localhost:8080/init") + .then(() => fetchOrders()) + .catch((e) => displayError(e)); + } + + function renderOrders() { + appLoadingEle.classList.add("d-none"); + orderWrapperEle.classList.remove("d-none"); + addOrderEle.classList.remove("d-none"); + + if (orders.length === 0) { + orderEmptyTextEle.classList.remove("d-none"); + orderTableEle.classList.add("d-none"); + return; + } + + orderEmptyTextEle.classList.add("d-none"); + orderTableEle.classList.remove("d-none"); + + while (orderTableBodyEle.firstChild) { + orderTableBodyEle.removeChild(orderTableBodyEle.firstChild); + } + + orders.forEach((order) => { + const orderId = order.order_id; + + const row = document.createElement("tr"); + + row.appendChild(createCell(order.order_id)); + row.appendChild(createCell(order.product_id)); + row.appendChild(createCell(order.quantity)); + row.appendChild(createCell(order.amount)); + row.appendChild(createCell(order.shipping)); + row.appendChild(createCell(order.tax)); + row.appendChild(createCell(order.shipping_address)); + + const actionCell = document.createElement("td"); + + const deleteButton = document.createElement("button"); + deleteButton.classList.add(...["btn","btn-sm","btn-danger"]); + deleteButton.innerText = "Delete"; + + deleteButton.addEventListener("click", (e) => { + e.preventDefault(); + deleteOrder(orderId); + }); + + actionCell.appendChild(deleteButton); + + row.appendChild(actionCell); + + orderTableBodyEle.appendChild(row); + }); + } + + function createCell(contents) { + const cell = document.createElement("td"); + cell.innerText = contents; + return cell; + } + + function deleteOrder(orderId) { + fetch(`http://localhost:8080/delete_order?id=${orderId}`) + .then(() => fetchOrders()); + } + + function displayError(err) { + alert("Error:" + err); + } + + function onAddFormSubmit(e) { + e.preventDefault(); + + const data = { + order_id : parseFloat(orderIdField.value), + product_id : parseFloat(productIdField.value), + quantity : parseFloat(quantityField.value), + amount : parseFloat(amountField.value), + shipping : parseFloat(shippingField.value), + tax : parseFloat(taxField.value), + shipping_address : shippingAddressField.value, + }; + + fetch("http://localhost:8080/create_order", { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-type": "application/json" }, + }).then(() => fetchOrders()) + .then(() => resetAddOrderForm()); + + alert("Order added"); + } + + function resetAddOrderForm() { + orderIdField.value = ""; + productIdField.value = ""; + quantityField.value = ""; + amountField.value = ""; + shippingField.value = ""; + taxField.value = ""; + shippingAddressField.value = ""; + } + + fetchOrders(); + addOrderForm.addEventListener("submit", onAddFormSubmit); +})(); \ No newline at end of file