From 4ef81c31a7aa566283a5a32d0a827c8364799173 Mon Sep 17 00:00:00 2001 From: Pat Hickey Date: Fri, 17 Oct 2025 10:18:43 -0700 Subject: [PATCH 01/11] add wstd-axum, for http servers written using the axum framework --- .github/workflows/ci.yaml | 2 +- Cargo.toml | 8 +++++- axum/Cargo.toml | 10 ++++++++ axum/examples/hello_world.rs | 18 ++++++++++++++ axum/macro/.gitignore | 1 + axum/macro/Cargo.toml | 11 +++++++++ axum/macro/src/lib.rs | 47 ++++++++++++++++++++++++++++++++++++ axum/src/lib.rs | 27 +++++++++++++++++++++ 8 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 axum/Cargo.toml create mode 100644 axum/examples/hello_world.rs create mode 100644 axum/macro/.gitignore create mode 100644 axum/macro/Cargo.toml create mode 100644 axum/macro/src/lib.rs create mode 100644 axum/src/lib.rs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a21bef2..a52f358 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -30,7 +30,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: check - args: --all --bins --examples + args: --workspace --all --bins --examples - name: wstd tests uses: actions-rs/cargo@v1 diff --git a/Cargo.toml b/Cargo.toml index 0a3cce5..069ba49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,8 @@ serde_json.workspace = true [workspace] members = [ + "axum", + "axum/macro", "macro", "test-programs", "test-programs/artifacts", @@ -71,6 +73,7 @@ authors = [ anyhow = "1" async-task = "4.7" async-trait = "*" +axum = { version = "0.8.6", default-features = false } bytes = "1.10.1" cargo_metadata = "0.22" clap = { version = "4.5.26", features = ["derive"] } @@ -92,10 +95,13 @@ syn = "2.0" test-log = { version = "0.2", features = ["trace"] } test-programs = { path = "test-programs" } test-programs-artifacts = { path = "test-programs/artifacts" } +tower-service = "0.3.3" ureq = { version = "2.12.1", default-features = false } wasip2 = "1.0" wstd = { path = "." } -wstd-macro = { path = "macro", version = "=0.5.6" } +wstd-axum = { path = "./axum", version = "0.5.6" } +wstd-axum-macro = { path = "./axum/macro", version = "=0.5.6" } +wstd-macro = { path = "./macro", version = "=0.5.6" } [package.metadata.docs.rs] all-features = true diff --git a/axum/Cargo.toml b/axum/Cargo.toml new file mode 100644 index 0000000..046608e --- /dev/null +++ b/axum/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "wstd-axum" +version = "0.5.6" +edition = "2024" + +[dependencies] +axum.workspace = true +tower-service.workspace = true +wstd.workspace = true +wstd-axum-macro.workspace = true diff --git a/axum/examples/hello_world.rs b/axum/examples/hello_world.rs new file mode 100644 index 0000000..32e96f9 --- /dev/null +++ b/axum/examples/hello_world.rs @@ -0,0 +1,18 @@ +//! Run with +//! +//! ```not_rust +//! cargo build -p example-hello-world --target wasm32-wasip2 +//! wasmtime serve -Scli target/wasm32-wasip2/debug/example-hello-world.wasm +//! ``` + +use axum::{response::Html, routing::get, Router}; + +#[wstd_axum::http_server] +fn main() -> Router { + // build our application with a route + Router::new().route("/", get(handler)) +} + +async fn handler() -> Html<&'static str> { + Html("

Hello, World!

") +} diff --git a/axum/macro/.gitignore b/axum/macro/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/axum/macro/.gitignore @@ -0,0 +1 @@ +/target diff --git a/axum/macro/Cargo.toml b/axum/macro/Cargo.toml new file mode 100644 index 0000000..c0abd19 --- /dev/null +++ b/axum/macro/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "wstd-axum-macro" +version = "0.5.6" +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +syn.workspace = true +quote.workspace = true diff --git a/axum/macro/src/lib.rs b/axum/macro/src/lib.rs new file mode 100644 index 0000000..b74c502 --- /dev/null +++ b/axum/macro/src/lib.rs @@ -0,0 +1,47 @@ +use proc_macro::TokenStream; +use quote::{quote, quote_spanned}; +use syn::{ItemFn, parse_macro_input, spanned::Spanned}; + +#[proc_macro_attribute] +pub fn attr_macro_http_server(_attr: TokenStream, item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as ItemFn); + + if input.sig.ident != "main" { + return quote_spanned! { input.sig.ident.span()=> + compile_error!("only `fn main` can be used for #[wstd_axum::http_server]"); + } + .into(); + } + + if !input.sig.inputs.is_empty() { + return quote_spanned! { input.sig.inputs.span()=> + compile_error!("arguments to main are not supported"); + } + .into(); + } + let (async_, call) = if input.sig.asyncness.is_some() { + (quote!(async), quote!(__make_service().await)) + } else { + (quote!(), quote!(__make_service())) + }; + let attrs = input.attrs; + let output = input.sig.output; + let block = input.block; + quote! { + #[::wstd::http_server] + pub async fn main( + __request: ::wstd::http::Request<::wstd::http::Body> + ) -> ::wstd::http::error::Result<::wstd::http::Response<::wstd::http::Body>> { + + #(#attrs)* + #async_ fn __make_service() #output { + #block + } + + let __service = #call; + + ::wstd_axum::serve(__request, __service).await + } + } + .into() +} diff --git a/axum/src/lib.rs b/axum/src/lib.rs new file mode 100644 index 0000000..7bb4f14 --- /dev/null +++ b/axum/src/lib.rs @@ -0,0 +1,27 @@ +use axum::extract::Request; +use axum::response::Response; +use std::convert::Infallible; +use tower_service::Service; + +pub use wstd_axum_macro::attr_macro_http_server as http_server; + +pub async fn serve( + request: wstd::http::Request, + mut service: S, +) -> wstd::http::error::Result> +where + S: Service + Clone + Send + 'static, + S::Future: Send, +{ + let resp = service + .call( + request.map(|incoming: wstd::http::Body| -> axum::body::Body { + axum::body::Body::new(incoming.into_boxed_body()) + }), + ) + .await + .unwrap_or_else(|err| match err {}); + Ok(resp.map(|body: axum::body::Body| -> wstd::http::Body { + wstd::http::Body::from_http_body(body) + })) +} From 9142f1559043150831e9a539d61e84937c118971 Mon Sep 17 00:00:00 2001 From: Pat Hickey Date: Fri, 17 Oct 2025 10:45:07 -0700 Subject: [PATCH 02/11] flatten unnecessary test-programs/artifacts crate into test-programs and add test for axum hello world. rather than need separate test-programs to build the bins, just build the examples. (I have no idea why I didn't do this in the first place.) --- .github/workflows/ci.yaml | 2 +- Cargo.toml | 2 - axum/examples/hello_world.rs | 4 +- test-programs/Cargo.toml | 16 ++-- test-programs/artifacts/Cargo.toml | 19 ---- test-programs/artifacts/build.rs | 69 -------------- test-programs/build.rs | 95 +++++++++++++++++++ test-programs/src/bin/http_server.rs | 1 - test-programs/src/bin/tcp_echo_server.rs | 1 - test-programs/{artifacts => }/src/lib.rs | 0 test-programs/tests/axum_hello_world.rs | 46 +++++++++ .../{artifacts => }/tests/http_server.rs | 2 +- .../{artifacts => }/tests/tcp_echo_server.rs | 4 +- 13 files changed, 157 insertions(+), 104 deletions(-) delete mode 100644 test-programs/artifacts/Cargo.toml delete mode 100644 test-programs/artifacts/build.rs create mode 100644 test-programs/build.rs delete mode 100644 test-programs/src/bin/http_server.rs delete mode 100644 test-programs/src/bin/tcp_echo_server.rs rename test-programs/{artifacts => }/src/lib.rs (100%) create mode 100644 test-programs/tests/axum_hello_world.rs rename test-programs/{artifacts => }/tests/http_server.rs (99%) rename test-programs/{artifacts => }/tests/tcp_echo_server.rs (96%) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a52f358..a468cb9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -42,7 +42,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: test - args: -p test-programs-artifacts -- --nocapture + args: -p test-programs -- --nocapture check_fmt_and_docs: diff --git a/Cargo.toml b/Cargo.toml index 069ba49..1184eda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,6 @@ members = [ "axum/macro", "macro", "test-programs", - "test-programs/artifacts", ] resolver = "2" @@ -94,7 +93,6 @@ slab = "0.4.9" syn = "2.0" test-log = { version = "0.2", features = ["trace"] } test-programs = { path = "test-programs" } -test-programs-artifacts = { path = "test-programs/artifacts" } tower-service = "0.3.3" ureq = { version = "2.12.1", default-features = false } wasip2 = "1.0" diff --git a/axum/examples/hello_world.rs b/axum/examples/hello_world.rs index 32e96f9..00eaa8f 100644 --- a/axum/examples/hello_world.rs +++ b/axum/examples/hello_world.rs @@ -1,11 +1,11 @@ //! Run with //! -//! ```not_rust +//! ```sh //! cargo build -p example-hello-world --target wasm32-wasip2 //! wasmtime serve -Scli target/wasm32-wasip2/debug/example-hello-world.wasm //! ``` -use axum::{response::Html, routing::get, Router}; +use axum::{Router, response::Html, routing::get}; #[wstd_axum::http_server] fn main() -> Router { diff --git a/test-programs/Cargo.toml b/test-programs/Cargo.toml index 94eaa2a..bd81505 100644 --- a/test-programs/Cargo.toml +++ b/test-programs/Cargo.toml @@ -1,14 +1,18 @@ [package] name = "test-programs" version = "0.1.0" -publish = false -license.workspace = true edition.workspace = true +license.workspace = true rust-version.workspace = true +publish = false [dependencies] + +[dev-dependencies] anyhow.workspace = true -futures-lite.workspace = true -http-body-util.workspace = true -serde_json.workspace = true -wstd.workspace = true +test-log.workspace = true +ureq.workspace = true + +[build-dependencies] +cargo_metadata.workspace = true +heck.workspace = true diff --git a/test-programs/artifacts/Cargo.toml b/test-programs/artifacts/Cargo.toml deleted file mode 100644 index 7c69049..0000000 --- a/test-programs/artifacts/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "test-programs-artifacts" -version = "0.1.0" -edition.workspace = true -license.workspace = true -rust-version.workspace = true -publish = false - -[dependencies] - -[dev-dependencies] -anyhow.workspace = true -test-log.workspace = true -test-programs-artifacts.workspace = true -ureq.workspace = true - -[build-dependencies] -cargo_metadata.workspace = true -heck.workspace = true diff --git a/test-programs/artifacts/build.rs b/test-programs/artifacts/build.rs deleted file mode 100644 index 9dea95b..0000000 --- a/test-programs/artifacts/build.rs +++ /dev/null @@ -1,69 +0,0 @@ -use cargo_metadata::TargetKind; -use heck::ToShoutySnakeCase; -use std::env::var_os; -use std::path::PathBuf; -use std::process::Command; - -fn main() { - let out_dir = PathBuf::from(var_os("OUT_DIR").expect("OUT_DIR env var exists")); - - let meta = cargo_metadata::MetadataCommand::new() - .exec() - .expect("cargo metadata"); - let test_programs_meta = meta - .packages - .iter() - .find(|p| *p.name == "test-programs") - .expect("test-programs is in cargo metadata"); - let test_programs_root = test_programs_meta.manifest_path.parent().unwrap(); - println!( - "cargo:rerun-if-changed={}", - test_programs_root.as_os_str().to_str().unwrap() - ); - - let wstd_root = test_programs_root.parent().unwrap(); - println!( - "cargo:rerun-if-changed={}", - wstd_root.as_os_str().to_str().unwrap() - ); - - let status = Command::new("cargo") - .arg("build") - .arg("--target=wasm32-wasip2") - .arg("--package=test-programs") - .env("CARGO_TARGET_DIR", &out_dir) - .env("CARGO_PROFILE_DEV_DEBUG", "2") - .env("RUSTFLAGS", rustflags()) - .env_remove("CARGO_ENCODED_RUSTFLAGS") - .status() - .expect("cargo build test programs"); - assert!(status.success()); - - let mut generated_code = "// THIS FILE IS GENERATED CODE\n".to_string(); - - for binary in test_programs_meta - .targets - .iter() - .filter(|t| t.kind == [TargetKind::Bin]) - { - let component_path = out_dir - .join("wasm32-wasip2") - .join("debug") - .join(format!("{}.wasm", binary.name)); - - let const_name = binary.name.to_shouty_snake_case(); - generated_code += &format!( - "pub const {const_name}: &str = {:?};\n", - component_path.as_os_str().to_str().expect("path is str") - ); - } - - std::fs::write(out_dir.join("gen.rs"), generated_code).unwrap(); -} - -fn rustflags() -> &'static str { - match option_env!("RUSTFLAGS") { - Some(s) if s.contains("-D warnings") => "-D warnings", - _ => "", - } -} diff --git a/test-programs/build.rs b/test-programs/build.rs new file mode 100644 index 0000000..12574e6 --- /dev/null +++ b/test-programs/build.rs @@ -0,0 +1,95 @@ +use cargo_metadata::TargetKind; +use heck::ToShoutySnakeCase; +use std::env::var_os; +use std::path::PathBuf; +use std::process::Command; + +fn main() { + let out_dir = PathBuf::from(var_os("OUT_DIR").expect("OUT_DIR env var exists")); + + let meta = cargo_metadata::MetadataCommand::new() + .exec() + .expect("cargo metadata"); + let wstd_meta = meta + .packages + .iter() + .find(|p| *p.name == "wstd") + .expect("wstd is in cargo metadata"); + let wstd_axum_meta = meta + .packages + .iter() + .find(|p| *p.name == "wstd-axum") + .expect("wstd is in cargo metadata"); + + let wstd_root = wstd_meta.manifest_path.parent().unwrap(); + println!( + "cargo:rerun-if-changed={}", + wstd_root.as_os_str().to_str().unwrap() + ); + + fn build_examples(pkg: &str, out_dir: &PathBuf) { + let status = Command::new("cargo") + .arg("build") + .arg("--examples") + .arg("--target=wasm32-wasip2") + .arg(format!("--package={pkg}")) + .env("CARGO_TARGET_DIR", out_dir) + .env("CARGO_PROFILE_DEV_DEBUG", "2") + .env("RUSTFLAGS", rustflags()) + .env_remove("CARGO_ENCODED_RUSTFLAGS") + .status() + .expect("cargo build wstd examples"); + assert!(status.success()); + } + build_examples("wstd", &out_dir); + build_examples("wstd-axum", &out_dir); + + let mut generated_code = "// THIS FILE IS GENERATED CODE\n".to_string(); + + for binary in wstd_meta + .targets + .iter() + .filter(|t| t.kind == [TargetKind::Example]) + { + let component_path = out_dir + .join("wasm32-wasip2") + .join("debug") + .join("examples") + .join(format!("{}.wasm", binary.name)); + + let const_name = binary.name.to_shouty_snake_case(); + generated_code += &format!( + "pub const {const_name}: &str = {:?};\n", + component_path.as_os_str().to_str().expect("path is str") + ); + } + + generated_code += "pub mod axum {"; + for binary in wstd_axum_meta + .targets + .iter() + .filter(|t| t.kind == [TargetKind::Example]) + { + let component_path = out_dir + .join("wasm32-wasip2") + .join("debug") + .join("examples") + .join(format!("{}.wasm", binary.name)); + + let const_name = binary.name.to_shouty_snake_case(); + generated_code += &format!( + "pub const {const_name}: &str = {:?};\n", + component_path.as_os_str().to_str().expect("path is str") + ); + } + generated_code += "}"; // end `pub mod axum` + + std::fs::write(out_dir.join("gen.rs"), generated_code).unwrap(); +} + +fn rustflags() -> &'static str { + match option_env!("RUSTFLAGS") { + Some(s) if s.contains("-D warnings") => "-D warnings", + _ => "", + } +} diff --git a/test-programs/src/bin/http_server.rs b/test-programs/src/bin/http_server.rs deleted file mode 100644 index e9fea26..0000000 --- a/test-programs/src/bin/http_server.rs +++ /dev/null @@ -1 +0,0 @@ -include!("../../../examples/http_server.rs"); diff --git a/test-programs/src/bin/tcp_echo_server.rs b/test-programs/src/bin/tcp_echo_server.rs deleted file mode 100644 index d46adb1..0000000 --- a/test-programs/src/bin/tcp_echo_server.rs +++ /dev/null @@ -1 +0,0 @@ -include!("../../../examples/tcp_echo_server.rs"); diff --git a/test-programs/artifacts/src/lib.rs b/test-programs/src/lib.rs similarity index 100% rename from test-programs/artifacts/src/lib.rs rename to test-programs/src/lib.rs diff --git a/test-programs/tests/axum_hello_world.rs b/test-programs/tests/axum_hello_world.rs new file mode 100644 index 0000000..0e82040 --- /dev/null +++ b/test-programs/tests/axum_hello_world.rs @@ -0,0 +1,46 @@ +use anyhow::Result; +use std::net::TcpStream; +use std::process::{Child, Command}; +use std::thread::sleep; +use std::time::Duration; + +// Wasmtime serve will run until killed. Kill it in a drop impl so the process +// isnt orphaned when the test suite ends (successfully, or unsuccessfully) +struct DontOrphan(Child); +impl Drop for DontOrphan { + fn drop(&mut self) { + let _ = self.0.kill(); + } +} + +#[test_log::test] +fn http_server() -> Result<()> { + // Run wasmtime serve. + // Enable -Scli because we currently don't have a way to build with the + // proxy adapter, so we build with the default adapter. + let _wasmtime_process = DontOrphan( + Command::new("wasmtime") + .arg("serve") + .arg("-Scli") + .arg("--addr=127.0.0.1:8081") + .arg(test_programs::axum::HELLO_WORLD) + .spawn()?, + ); + + // Clumsily wait for the server to accept connections. + 'wait: loop { + sleep(Duration::from_millis(100)); + if TcpStream::connect("127.0.0.1:8081").is_ok() { + break 'wait; + } + } + + // Test each path in the server: + + // TEST / handler + // Response body is the hard-coded default + let body: String = ureq::get("http://127.0.0.1:8081").call()?.into_string()?; + assert!(body.contains("

Hello, World!

")); + + Ok(()) +} diff --git a/test-programs/artifacts/tests/http_server.rs b/test-programs/tests/http_server.rs similarity index 99% rename from test-programs/artifacts/tests/http_server.rs rename to test-programs/tests/http_server.rs index 13ce30f..df96392 100644 --- a/test-programs/artifacts/tests/http_server.rs +++ b/test-programs/tests/http_server.rs @@ -23,7 +23,7 @@ fn http_server() -> Result<()> { .arg("serve") .arg("-Scli") .arg("--addr=127.0.0.1:8081") - .arg(test_programs_artifacts::HTTP_SERVER) + .arg(test_programs::HTTP_SERVER) .spawn()?, ); diff --git a/test-programs/artifacts/tests/tcp_echo_server.rs b/test-programs/tests/tcp_echo_server.rs similarity index 96% rename from test-programs/artifacts/tests/tcp_echo_server.rs rename to test-programs/tests/tcp_echo_server.rs index 3cf7752..1e949cc 100644 --- a/test-programs/artifacts/tests/tcp_echo_server.rs +++ b/test-programs/tests/tcp_echo_server.rs @@ -6,14 +6,14 @@ fn tcp_echo_server() -> Result<()> { use std::io::{Read, Write}; use std::net::{Shutdown, TcpStream}; - println!("testing {}", test_programs_artifacts::TCP_ECHO_SERVER); + println!("testing {}", test_programs::TCP_ECHO_SERVER); // Run the component in wasmtime // -Sinherit-network required for sockets to work let mut wasmtime_process = Command::new("wasmtime") .arg("run") .arg("-Sinherit-network") - .arg(test_programs_artifacts::TCP_ECHO_SERVER) + .arg(test_programs::TCP_ECHO_SERVER) .stdout(std::process::Stdio::piped()) .spawn()?; From 49ef951662cff204fb0b418261fb669fd5d74245 Mon Sep 17 00:00:00 2001 From: Pat Hickey Date: Fri, 17 Oct 2025 11:02:04 -0700 Subject: [PATCH 03/11] add to publish --- axum/Cargo.toml | 11 +++++++++-- axum/examples/hello_world.rs | 6 +++--- axum/macro/Cargo.toml | 11 +++++++++-- axum/macro/src/lib.rs | 2 +- ci/publish.rs | 2 +- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/axum/Cargo.toml b/axum/Cargo.toml index 046608e..dfd4879 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -1,7 +1,14 @@ [package] name = "wstd-axum" -version = "0.5.6" -edition = "2024" +description = "Support for axum as a wasi http server via wstd" +version.workspace = true +license.workspace = true +edition.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true +repository.workspace = true +rust-version.workspace = true [dependencies] axum.workspace = true diff --git a/axum/examples/hello_world.rs b/axum/examples/hello_world.rs index 00eaa8f..9cb642c 100644 --- a/axum/examples/hello_world.rs +++ b/axum/examples/hello_world.rs @@ -1,11 +1,11 @@ //! Run with //! //! ```sh -//! cargo build -p example-hello-world --target wasm32-wasip2 -//! wasmtime serve -Scli target/wasm32-wasip2/debug/example-hello-world.wasm +//! cargo build -p wstd-axum --examples --target wasm32-wasip2 +//! wasmtime serve -Scli target/wasm32-wasip2/debug/examples/hello-world.wasm //! ``` -use axum::{Router, response::Html, routing::get}; +use axum::{response::Html, routing::get, Router}; #[wstd_axum::http_server] fn main() -> Router { diff --git a/axum/macro/Cargo.toml b/axum/macro/Cargo.toml index c0abd19..989ea42 100644 --- a/axum/macro/Cargo.toml +++ b/axum/macro/Cargo.toml @@ -1,7 +1,14 @@ [package] name = "wstd-axum-macro" -version = "0.5.6" -edition = "2024" +description = "Proc-macro support for axum as a wasi http server via wstd" +version.workspace = true +license.workspace = true +edition.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true +repository.workspace = true +rust-version.workspace = true [lib] proc-macro = true diff --git a/axum/macro/src/lib.rs b/axum/macro/src/lib.rs index b74c502..0d97263 100644 --- a/axum/macro/src/lib.rs +++ b/axum/macro/src/lib.rs @@ -1,6 +1,6 @@ use proc_macro::TokenStream; use quote::{quote, quote_spanned}; -use syn::{ItemFn, parse_macro_input, spanned::Spanned}; +use syn::{parse_macro_input, spanned::Spanned, ItemFn}; #[proc_macro_attribute] pub fn attr_macro_http_server(_attr: TokenStream, item: TokenStream) -> TokenStream { diff --git a/ci/publish.rs b/ci/publish.rs index 43da6d3..bd20833 100644 --- a/ci/publish.rs +++ b/ci/publish.rs @@ -16,7 +16,7 @@ use std::thread; use std::time::Duration; // note that this list must be topologically sorted by dependencies -const CRATES_TO_PUBLISH: &[&str] = &["wstd-macro", "wstd"]; +const CRATES_TO_PUBLISH: &[&str] = &["wstd-macro", "wstd", "wstd-axum-macro", "wstd-axum"]; #[derive(Debug)] struct Workspace { From 4a67c8f3d98c9b182e36b131b7d1de75ce942c5c Mon Sep 17 00:00:00 2001 From: Pat Hickey Date: Fri, 17 Oct 2025 11:46:10 -0700 Subject: [PATCH 04/11] workspace edition 2024 --- Cargo.toml | 2 +- axum/examples/hello_world.rs | 2 +- axum/macro/src/lib.rs | 2 +- examples/complex_http_client.rs | 10 ++-------- examples/http_client.rs | 2 +- macro/src/lib.rs | 2 +- src/future/delay.rs | 2 +- src/http/body.rs | 16 ++++++++-------- src/http/fields.rs | 2 +- src/http/method.rs | 2 +- src/http/mod.rs | 2 +- src/http/request.rs | 2 +- src/http/response.rs | 2 +- src/http/server.rs | 2 +- src/io/streams.rs | 12 ++++++------ src/runtime/block_on.rs | 2 +- tests/http_first_byte_timeout.rs | 2 +- 17 files changed, 30 insertions(+), 36 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1184eda..0db5a91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ resolver = "2" [workspace.package] version = "0.5.6" -edition = "2021" +edition = "2024" license = "Apache-2.0 WITH LLVM-exception" repository = "https://github.com/bytecodealliance/wstd" keywords = ["WebAssembly", "async", "stdlib", "Components"] diff --git a/axum/examples/hello_world.rs b/axum/examples/hello_world.rs index 9cb642c..90a9cbd 100644 --- a/axum/examples/hello_world.rs +++ b/axum/examples/hello_world.rs @@ -5,7 +5,7 @@ //! wasmtime serve -Scli target/wasm32-wasip2/debug/examples/hello-world.wasm //! ``` -use axum::{response::Html, routing::get, Router}; +use axum::{Router, response::Html, routing::get}; #[wstd_axum::http_server] fn main() -> Router { diff --git a/axum/macro/src/lib.rs b/axum/macro/src/lib.rs index 0d97263..b74c502 100644 --- a/axum/macro/src/lib.rs +++ b/axum/macro/src/lib.rs @@ -1,6 +1,6 @@ use proc_macro::TokenStream; use quote::{quote, quote_spanned}; -use syn::{parse_macro_input, spanned::Spanned, ItemFn}; +use syn::{ItemFn, parse_macro_input, spanned::Spanned}; #[proc_macro_attribute] pub fn attr_macro_http_server(_attr: TokenStream, item: TokenStream) -> TokenStream { diff --git a/examples/complex_http_client.rs b/examples/complex_http_client.rs index 2d4acb0..703e931 100644 --- a/examples/complex_http_client.rs +++ b/examples/complex_http_client.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use clap::{ArgAction, Parser}; use std::str::FromStr; use wstd::http::{Body, BodyExt, Client, HeaderMap, HeaderName, HeaderValue, Method, Request, Uri}; @@ -91,13 +91,7 @@ async fn main() -> Result<()> { Body::empty().into_boxed_body() }; let t = trailers.clone(); - let body = body.with_trailers(async move { - if t.is_empty() { - None - } else { - Some(Ok(t)) - } - }); + let body = body.with_trailers(async move { if t.is_empty() { None } else { Some(Ok(t)) } }); let request = request.body(Body::from_http_body(body))?; // Send the request. diff --git a/examples/http_client.rs b/examples/http_client.rs index f9e5e4c..f4465a8 100644 --- a/examples/http_client.rs +++ b/examples/http_client.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use clap::{ArgAction, Parser}; use wstd::http::{Body, BodyExt, Client, Method, Request, Uri}; use wstd::io::AsyncWrite; diff --git a/macro/src/lib.rs b/macro/src/lib.rs index bfc488c..42efa51 100644 --- a/macro/src/lib.rs +++ b/macro/src/lib.rs @@ -1,6 +1,6 @@ use proc_macro::TokenStream; use quote::{quote, quote_spanned}; -use syn::{parse_macro_input, spanned::Spanned, ItemFn}; +use syn::{ItemFn, parse_macro_input, spanned::Spanned}; #[proc_macro_attribute] pub fn attr_macro_main(_attr: TokenStream, item: TokenStream) -> TokenStream { diff --git a/src/future/delay.rs b/src/future/delay.rs index d8fcdbf..20d6753 100644 --- a/src/future/delay.rs +++ b/src/future/delay.rs @@ -1,6 +1,6 @@ use std::future::Future; use std::pin::Pin; -use std::task::{ready, Context, Poll}; +use std::task::{Context, Poll, ready}; use pin_project_lite::pin_project; diff --git a/src/http/body.rs b/src/http/body.rs index e30c053..c23a3c0 100644 --- a/src/http/body.rs +++ b/src/http/body.rs @@ -1,7 +1,7 @@ use crate::http::{ + Error, HeaderMap, error::Context as _, fields::{header_map_from_wasi, header_map_to_wasi}, - Error, HeaderMap, }; use crate::io::{AsyncInputStream, AsyncOutputStream}; use crate::runtime::{AsyncPollable, Reactor, WaitFor}; @@ -10,10 +10,10 @@ pub use ::http_body::{Body as HttpBody, Frame, SizeHint}; pub use bytes::Bytes; use http::header::CONTENT_LENGTH; -use http_body_util::{combinators::UnsyncBoxBody, BodyExt}; +use http_body_util::{BodyExt, combinators::UnsyncBoxBody}; use std::fmt; -use std::future::{poll_fn, Future}; -use std::pin::{pin, Pin}; +use std::future::{Future, poll_fn}; +use std::pin::{Pin, pin}; use std::task::{Context, Poll}; use wasip2::http::types::{ FutureTrailers, IncomingBody as WasiIncomingBody, OutgoingBody as WasiOutgoingBody, @@ -145,7 +145,7 @@ impl Body { /// copied into memory, or an error occurs. pub async fn contents(&mut self) -> Result<&[u8], Error> { match &mut self.0 { - BodyInner::Complete { ref data, .. } => Ok(data.as_ref()), + BodyInner::Complete { data, .. } => Ok(&*data), inner => { let mut prev = BodyInner::Complete { data: Bytes::new(), @@ -164,7 +164,7 @@ impl Body { trailers, }; Ok(match inner { - BodyInner::Complete { ref data, .. } => data.as_ref(), + BodyInner::Complete { data, .. } => &*data, _ => unreachable!(), }) } @@ -511,13 +511,13 @@ impl BodyState { loop { match self.stream.read(MAX_FRAME_SIZE) { Ok(bs) if !bs.is_empty() => { - return Poll::Ready(Some(Ok(Frame::data(Bytes::from(bs))))) + return Poll::Ready(Some(Ok(Frame::data(Bytes::from(bs))))); } Err(StreamError::Closed) => return Poll::Ready(None), Err(StreamError::LastOperationFailed(err)) => { return Poll::Ready(Some(Err( Error::msg(err.to_debug_string()).context("reading incoming body stream") - ))) + ))); } Ok(_empty) => { if self.subscription.is_none() { diff --git a/src/http/fields.rs b/src/http/fields.rs index 35a9be7..de6df16 100644 --- a/src/http/fields.rs +++ b/src/http/fields.rs @@ -1,6 +1,6 @@ pub use http::header::{HeaderMap, HeaderName, HeaderValue}; -use super::{error::Context, Error}; +use super::{Error, error::Context}; use wasip2::http::types::Fields; pub(crate) fn header_map_from_wasi(wasi_fields: Fields) -> Result { diff --git a/src/http/method.rs b/src/http/method.rs index cee31e6..d1882a8 100644 --- a/src/http/method.rs +++ b/src/http/method.rs @@ -1,7 +1,7 @@ use wasip2::http::types::Method as WasiMethod; -use http::method::InvalidMethod; pub use http::Method; +use http::method::InvalidMethod; pub(crate) fn to_wasi_method(value: Method) -> WasiMethod { match value { diff --git a/src/http/mod.rs b/src/http/mod.rs index 9f41125..39f0a40 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -4,7 +4,7 @@ pub use http::status::StatusCode; pub use http::uri::{Authority, PathAndQuery, Uri}; #[doc(inline)] -pub use body::{util::BodyExt, Body}; +pub use body::{Body, util::BodyExt}; pub use client::Client; pub use error::{Error, ErrorCode, Result}; pub use fields::{HeaderMap, HeaderName, HeaderValue}; diff --git a/src/http/request.rs b/src/http/request.rs index f9a0817..6694d03 100644 --- a/src/http/request.rs +++ b/src/http/request.rs @@ -1,10 +1,10 @@ use super::{ + Authority, HeaderMap, PathAndQuery, Uri, body::{Body, BodyHint}, error::{Context, Error, ErrorCode}, fields::{header_map_from_wasi, header_map_to_wasi}, method::{from_wasi_method, to_wasi_method}, scheme::{from_wasi_scheme, to_wasi_scheme}, - Authority, HeaderMap, PathAndQuery, Uri, }; use wasip2::http::outgoing_handler::OutgoingRequest; use wasip2::http::types::IncomingRequest; diff --git a/src/http/response.rs b/src/http/response.rs index f0b57e8..d0af77e 100644 --- a/src/http/response.rs +++ b/src/http/response.rs @@ -3,7 +3,7 @@ use wasip2::http::types::IncomingResponse; use crate::http::body::{Body, BodyHint}; use crate::http::error::{Context, Error}; -use crate::http::fields::{header_map_from_wasi, HeaderMap}; +use crate::http::fields::{HeaderMap, header_map_from_wasi}; pub use http::response::{Builder, Response}; diff --git a/src/http/server.rs b/src/http/server.rs index 471330e..3ac12df 100644 --- a/src/http/server.rs +++ b/src/http/server.rs @@ -18,7 +18,7 @@ //! [`Response`]: crate::http::Response //! [`http_server`]: crate::http_server -use super::{error::ErrorCode, fields::header_map_to_wasi, Body, Error, Response}; +use super::{Body, Error, Response, error::ErrorCode, fields::header_map_to_wasi}; use http::header::CONTENT_LENGTH; use wasip2::exports::http::incoming_handler::ResponseOutparam; use wasip2::http::types::OutgoingResponse; diff --git a/src/io/streams.rs b/src/io/streams.rs index e5e889f..3676d21 100644 --- a/src/io/streams.rs +++ b/src/io/streams.rs @@ -1,6 +1,6 @@ use super::{AsyncPollable, AsyncRead, AsyncWrite}; use crate::runtime::WaitFor; -use std::future::{poll_fn, Future}; +use std::future::{Future, poll_fn}; use std::pin::Pin; use std::sync::{Mutex, OnceLock}; use std::task::{Context, Poll}; @@ -64,7 +64,7 @@ impl AsyncInputStream { // 0 bytes from Rust's `read` means end-of-stream. Err(StreamError::Closed) => return Ok(0), Err(StreamError::LastOperationFailed(err)) => { - return Err(std::io::Error::other(err.to_debug_string())) + return Err(std::io::Error::other(err.to_debug_string())); } } }; @@ -263,18 +263,18 @@ impl AsyncOutputStream { match self.stream.write(&buf[0..writable]) { Ok(()) => return Ok(writable), Err(StreamError::Closed) => { - return Err(std::io::Error::from(std::io::ErrorKind::ConnectionReset)) + return Err(std::io::Error::from(std::io::ErrorKind::ConnectionReset)); } Err(StreamError::LastOperationFailed(err)) => { - return Err(std::io::Error::other(err.to_debug_string())) + return Err(std::io::Error::other(err.to_debug_string())); } } } Err(StreamError::Closed) => { - return Err(std::io::Error::from(std::io::ErrorKind::ConnectionReset)) + return Err(std::io::Error::from(std::io::ErrorKind::ConnectionReset)); } Err(StreamError::LastOperationFailed(err)) => { - return Err(std::io::Error::other(err.to_debug_string())) + return Err(std::io::Error::other(err.to_debug_string())); } } } diff --git a/src/runtime/block_on.rs b/src/runtime/block_on.rs index 5c951ce..34f0dfa 100644 --- a/src/runtime/block_on.rs +++ b/src/runtime/block_on.rs @@ -1,4 +1,4 @@ -use super::{Reactor, REACTOR}; +use super::{REACTOR, Reactor}; use std::future::Future; use std::pin::pin; diff --git a/tests/http_first_byte_timeout.rs b/tests/http_first_byte_timeout.rs index 4882966..5fd47be 100644 --- a/tests/http_first_byte_timeout.rs +++ b/tests/http_first_byte_timeout.rs @@ -1,4 +1,4 @@ -use wstd::http::{error::ErrorCode, Body, Client, Request}; +use wstd::http::{Body, Client, Request, error::ErrorCode}; #[wstd::main] async fn main() -> Result<(), Box> { From dda12dfa436eb5b9d930c122ba918868f203c8b5 Mon Sep 17 00:00:00 2001 From: Pat Hickey Date: Fri, 17 Oct 2025 11:58:46 -0700 Subject: [PATCH 05/11] wstd-axum-macro requires syn features full --- axum/macro/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/axum/macro/Cargo.toml b/axum/macro/Cargo.toml index 989ea42..2b183d5 100644 --- a/axum/macro/Cargo.toml +++ b/axum/macro/Cargo.toml @@ -14,5 +14,5 @@ rust-version.workspace = true proc-macro = true [dependencies] -syn.workspace = true +syn = { workspace = true, features = ["full"] } quote.workspace = true From 0db0f51beae2ca2dd280e54ba3754e845494c7c7 Mon Sep 17 00:00:00 2001 From: Pat Hickey Date: Fri, 17 Oct 2025 12:11:36 -0700 Subject: [PATCH 06/11] workspace versioning --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0db5a91..8222955 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,8 +96,8 @@ test-programs = { path = "test-programs" } tower-service = "0.3.3" ureq = { version = "2.12.1", default-features = false } wasip2 = "1.0" -wstd = { path = "." } -wstd-axum = { path = "./axum", version = "0.5.6" } +wstd = { path = ".", version = "=0.5.6" } +wstd-axum = { path = "./axum", version = "=0.5.6" } wstd-axum-macro = { path = "./axum/macro", version = "=0.5.6" } wstd-macro = { path = "./macro", version = "=0.5.6" } From 9842b2aeb682b7be5e2915800414f1cc8d96a48d Mon Sep 17 00:00:00 2001 From: Pat Hickey Date: Fri, 17 Oct 2025 12:51:22 -0700 Subject: [PATCH 07/11] docs, example that doesnt use macro --- axum/examples/hello_world_nomacro.rs | 19 ++++++++++++ axum/src/lib.rs | 41 +++++++++++++++++++++++++ test-programs/tests/axum_hello_world.rs | 15 +++++++-- 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 axum/examples/hello_world_nomacro.rs diff --git a/axum/examples/hello_world_nomacro.rs b/axum/examples/hello_world_nomacro.rs new file mode 100644 index 0000000..f3068d1 --- /dev/null +++ b/axum/examples/hello_world_nomacro.rs @@ -0,0 +1,19 @@ +//! Run with +//! +//! ```sh +//! cargo build -p wstd-axum --examples --target wasm32-wasip2 +//! wasmtime serve -Scli target/wasm32-wasip2/debug/examples/hello-world-nomacro.wasm +//! ``` + +use axum::{Router, response::Html, routing::get}; +use wstd::http::{Body, Error, Request, Response}; + +#[wstd::http_server] +async fn main(request: Request) -> Result, Error> { + let service = Router::new().route("/", get(handler)); + wstd_axum::serve(request, service).await +} + +async fn handler() -> Html<&'static str> { + Html("

Hello, World!

") +} diff --git a/axum/src/lib.rs b/axum/src/lib.rs index 7bb4f14..3272b91 100644 --- a/axum/src/lib.rs +++ b/axum/src/lib.rs @@ -1,3 +1,44 @@ +//! Support for the [`axum`] web server framework in wasi-http components, via +//! [`wstd`]. +//! +//! This crate is a pretty thin wrapper on [`wstd`] that allows users to +//! use the [`axum`] crate on top of wstd's http support. This means that +//! axum services can run anywhere the [wasi-http proxy world] is supported, +//! e.g. in [`wasmtime serve`]. +//! +//! Users of this crate should depend on `axum` with `default-features = +//! false`, and opt in to any features that they require (e.g. form, json, +//! matched-path, original-uri, query, tower-log, tracing). The axum crate +//! features that require `hyper` or `tokio` are NOT supported (e.g. http1, +//! http2, ws), because unlike in native applications, wasi-http components +//! have an http implementation provided as imported interfaces (i.e. +//! implemented the Wasm host), and do not use raw sockets inside of this +//! program. +//! +//! # Examples +//! +//! The simplest use is via the `wstd_axum::http_server` proc macro. +//! This macro can be applied to a sync or `async` `fn main` which returns +//! an impl of the `tower_service::Service` trait, typically an +//! `axum::Router`: +//! +//! ```rust,no_run +#![doc = include_str!("../examples/hello_world.rs")] +//! ``` +//! +//! If users desire, they can instead use a `wstd::http_server` entry point +//! and then use `wstd_axum::serve` directly. The following is equivelant +//! to the above example: +//! +//! ```rust,no_run +#![doc = include_str!("../examples/hello_world_nomacro.rs")] +//! ``` +//! +//! [`axum`]: https://docs.rs/axum/latest/axum/ +//! [`wstd`]: https://docs.rs/wstd/latest/wstd/ +//! [wasi-http proxy world]: https://github.com/WebAssembly/wasi-http +//! [`wasmtime serve`]: https://wasmtime.dev/ + use axum::extract::Request; use axum::response::Response; use std::convert::Infallible; diff --git a/test-programs/tests/axum_hello_world.rs b/test-programs/tests/axum_hello_world.rs index 0e82040..1a33956 100644 --- a/test-programs/tests/axum_hello_world.rs +++ b/test-programs/tests/axum_hello_world.rs @@ -14,7 +14,18 @@ impl Drop for DontOrphan { } #[test_log::test] -fn http_server() -> Result<()> { +fn hello_world() -> Result<()> { + run(test_programs::axum::HELLO_WORLD) +} + +#[test_log::test] +fn hello_world_nomacro() -> Result<()> { + run(test_programs::axum::HELLO_WORLD_NOMACRO) +} + +// The hello_world.rs and hello_world_nomacro.rs are identical in +// functionality +fn run(guest: &str) -> Result<()> { // Run wasmtime serve. // Enable -Scli because we currently don't have a way to build with the // proxy adapter, so we build with the default adapter. @@ -23,7 +34,7 @@ fn http_server() -> Result<()> { .arg("serve") .arg("-Scli") .arg("--addr=127.0.0.1:8081") - .arg(test_programs::axum::HELLO_WORLD) + .arg(guest) .spawn()?, ); From a5e81cc831041d109301600fa5c81c45dabe7b25 Mon Sep 17 00:00:00 2001 From: Pat Hickey Date: Mon, 20 Oct 2025 12:07:19 -0700 Subject: [PATCH 08/11] add my own weather example for axum --- Cargo.toml | 1 + axum/Cargo.toml | 7 + axum/examples/weather.rs | 321 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 329 insertions(+) create mode 100644 axum/examples/weather.rs diff --git a/Cargo.toml b/Cargo.toml index 8222955..408df4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,7 @@ pin-project-lite = "0.2.8" quote = "1.0" serde= "1" serde_json = "1" +serde_qs = "0.15" slab = "0.4.9" syn = "2.0" test-log = { version = "0.2", features = ["trace"] } diff --git a/axum/Cargo.toml b/axum/Cargo.toml index dfd4879..154a021 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -15,3 +15,10 @@ axum.workspace = true tower-service.workspace = true wstd.workspace = true wstd-axum-macro.workspace = true + +[dev-dependencies] +anyhow.workspace = true +futures-concurrency.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_qs.workspace = true +axum = { workspace = true, features = ["query", "json", "macros"] } diff --git a/axum/examples/weather.rs b/axum/examples/weather.rs new file mode 100644 index 0000000..5163fcc --- /dev/null +++ b/axum/examples/weather.rs @@ -0,0 +1,321 @@ +//! This demo app shows a Axum based wasi-http server making an arbitrary +//! number of http requests as part of serving a single response. +//! +//! Use the request query string to pass the parameters `city`, and optionally +//! `count` (defaults to 10). This app will tell you the current weather in +//! a set of `count` locations matching the `city` name. For example, when +//! searching for `city=portland&count=2`, it will return Portland, OR and +//! then Portland, ME - location results are sorted by population. +//! +//! This app first makes a request to `geocoding-api.open-meteo.com` to search +//! for a set of `count` locations for a given `city` name. +//! +//! Then, it makes `count` requests to `api.open-meteo.com`'s forecast api to +//! get the current temperature and rain accumulation in each of those +//! locations. +//! +//! The complete set of locations and weather reports are retuned as a json +//! array of records. + +use anyhow::{Context, Result, anyhow}; +use axum::extract::{Json, Query}; +use axum::http::StatusCode as AxumStatusCode; +use axum::routing::{Router, get}; +use serde::{Deserialize, Serialize}; +use wstd::http::{Client, Request, StatusCode, Uri}; + +/// Be polite: user-agent tells server where these results came from, so they +/// can easily block abuse +const USER_AGENT: &str = "wstd-axum weather example (https://github.com/bytecodealliance/wstd)"; + +/// The axum http server serves just one route for get requests at /weather +#[wstd_axum::http_server] +fn main() -> Router { + Router::new().route("/weather", get(weather)) +} + +/// Named pair used as JSON response +#[derive(Serialize)] +struct LocationWeather { + location: Location, + weather: Weather, +} + +/// Whole demo app lives at this one endpoint. +async fn weather( + Query(query): Query, +) -> axum::response::Result>> { + if query.count == 0 { + Err((AxumStatusCode::BAD_REQUEST, "nonzero count required"))?; + } + // Search for the locations in the query + let location_results = fetch_locations(&query) + .await + .context("searching for location") + .map_err(anyhow_response)?; + + use futures_concurrency::future::TryJoin; + let results = location_results + .into_iter() + // For each location found, constuct a future which fetches the + // weather, then returns the record of location, weather + .map(|location| async move { + let weather = fetch_weather(&location) + .await + .with_context(|| format!("fetching weather for {}", location.qualified_name))?; + Ok::<_, anyhow::Error>(LocationWeather { location, weather }) + }) + // Collect a vec of futures + .collect::>() + // TryJoin::try_join takes a vec of futures which return a + // result, and gives a future which returns a + // result, error> + .try_join() + // Get all of the successful items, or else the first error to + // resolve. + .await + .map_err(anyhow_response)?; + Ok(Json(results)) +} + +/// The query string given to this server contains a city, and optionally a +/// count. +#[derive(Deserialize)] +struct WeatherQuery { + city: String, + #[serde(default = "default_count")] + count: u32, +} +/// When the count is not given in the query string, it defaults to this number +const fn default_count() -> u32 { + 10 +} +/// Default WeatherQuery for when none is given. Portland is a good enough location +/// for me, so its good enough for the demo. +impl Default for WeatherQuery { + fn default() -> Self { + WeatherQuery { + city: "Portland".to_string(), + count: default_count(), + } + } +} + +/// Location struct contains the fields we care from the location search. We +/// massage the geolocation API response down to these fields because we dont +/// care about a bunch of its contents. The Serialize allows us to return this +/// value in our server response json. +#[derive(Debug, Serialize)] +struct Location { + name: String, + qualified_name: String, + population: Option, + latitude: f64, + longitude: f64, +} + +/// Fetch the locations corresponding to the query from the open-meteo +/// geocoding API. +async fn fetch_locations(query: &WeatherQuery) -> Result> { + // Utility struct describes the fields we use in the geocoding api's query + // string + #[derive(Serialize)] + struct GeoQuery { + name: String, + count: u32, + language: String, + format: String, + } + // Value of the fields in the query string: + let geo_query = GeoQuery { + name: query.city.clone(), + count: query.count, + language: "en".to_string(), + format: "json".to_string(), + }; + + // Construct the request uri using serde_qs to serialize GeoQuery into a query string. + let uri = Uri::builder() + .scheme("http") + .authority("geocoding-api.open-meteo.com") + .path_and_query(format!( + "/v1/search?{}", + serde_qs::to_string(&geo_query).context("serialize query string")? + )) + .build()?; + // Request is a GET request with no body. User agent is polite to provide. + let request = Request::get(uri) + .header("User-Agent", USER_AGENT) + .body(())?; + + // Make the request + let resp = Client::new() + .send(request) + .await + .context("request to geocoding-api.open-meteo.com") + .context(AxumStatusCode::SERVICE_UNAVAILABLE)?; + // Die with 503 if geocoding api fails for some reason + if resp.status() != StatusCode::OK { + return Err(anyhow!("geocoding-api returned status {:?}", resp.status()) + .context(AxumStatusCode::SERVICE_UNAVAILABLE)); + } + + // Utility structs with Deserialize impls to extract the fields we care + // about from the API's json response. + #[derive(Deserialize)] + struct Contents { + results: Vec, + } + #[derive(Deserialize)] + struct Item { + name: String, + latitude: f64, + longitude: f64, + population: Option, + // There are up to 4 admin region strings provided, only the first one + // seems to be guaranteed to be delivered. If it was my API, I would + // have made a single field `admin` which has a list of strings, but + // its not my API! + admin1: String, + admin2: Option, + admin3: Option, + admin4: Option, + } + impl Item { + /// The API returns a set of "admin" names (for administrative + /// regions), pretty-print them from most specific to least specific: + fn qualified_name(&self) -> String { + let mut n = String::new(); + if let Some(name) = &self.admin4 { + n.push_str(name); + n.push_str(", "); + } + if let Some(name) = &self.admin3 { + n.push_str(name); + n.push_str(", "); + } + if let Some(name) = &self.admin2 { + n.push_str(name); + n.push_str(", "); + } + n.push_str(&self.admin1); + n + } + } + + // Collect the response body and parse the Contents field out of the json: + let contents: Contents = resp + .into_body() + .json() + .await + .context("parsing geocoding-api response")?; + + // Massage the Contents into a Vec. + let mut results = contents + .results + .into_iter() + .map(|item| { + let qualified_name = item.qualified_name(); + Location { + name: item.name, + latitude: item.latitude, + longitude: item.longitude, + population: item.population, + qualified_name, + } + }) + .collect::>(); + // Sort by highest population first. + results.sort_by(|a, b| b.population.partial_cmp(&a.population).unwrap()); + Ok(results) +} + +/// Weather struct contains the items in the weather report we care about: the +/// temperature, how much rain, and the units for each. The Serialize allows +/// us to return this value in our server response json. +#[derive(Debug, Serialize)] +struct Weather { + temp: f64, + temp_unit: String, + rain: f64, + rain_unit: String, +} + +/// Fetch the weather for a given location from the open-meto forecast API. +async fn fetch_weather(location: &Location) -> Result { + // Utility struct for the query string expected by the forecast API + #[derive(Serialize)] + struct ForecastQuery { + latitude: f64, + longitude: f64, + current: String, + } + // Value used for the forecast api query string + let query = ForecastQuery { + latitude: location.latitude, + longitude: location.longitude, + current: "temperature_2m,rain".to_string(), + }; + // Construct the uri to the forecast api, serializing the query string + // with serde_qs. + let uri = Uri::builder() + .scheme("http") + .authority("api.open-meteo.com") + .path_and_query(format!( + "/v1/forecast?{}", + serde_qs::to_string(&query).context("serialize query string")? + )) + .build()?; + // Make the GET request, attaching user-agent, empty body. + let request = Request::get(uri) + .header("User-Agent", USER_AGENT) + .body(())?; + let mut resp = Client::new() + .send(request) + .await + .context("request to api.open-meteo.com") + .context(AxumStatusCode::SERVICE_UNAVAILABLE)?; + + // Bubble up error if forecast api failed + if resp.status() != StatusCode::OK { + return Err(anyhow!("forecast api returned status {:?}", resp.status()) + .context(AxumStatusCode::SERVICE_UNAVAILABLE)); + } + + // Utility structs for extracting fields from the forecast api's json + // response. + #[derive(Deserialize)] + struct Contents { + current_units: Units, + current: Data, + } + #[derive(Deserialize)] + struct Units { + temperature_2m: String, + rain: String, + } + #[derive(Deserialize)] + struct Data { + temperature_2m: f64, + rain: f64, + } + + // Parse the contents of the json response + let contents: Contents = resp.body_mut().json().await?; + // Massage those structs into a single Weather + let weather = Weather { + temp: contents.current.temperature_2m, + temp_unit: contents.current_units.temperature_2m, + rain: contents.current.rain, + rain_unit: contents.current_units.rain, + }; + Ok(weather) +} + +fn anyhow_response(e: anyhow::Error) -> axum::response::ErrorResponse { + let code = e + .downcast_ref::() + .cloned() + .unwrap_or(AxumStatusCode::INTERNAL_SERVER_ERROR); + (code, format!("{e:?}")).into() +} From d0dae63ff922b303882238a44116ac6e29bbc7f4 Mon Sep 17 00:00:00 2001 From: Pat Hickey Date: Mon, 20 Oct 2025 15:45:32 -0700 Subject: [PATCH 09/11] add test for axum weather example. upgrade all tests to ureq 3 --- Cargo.toml | 2 +- examples/http_server.rs | 8 ++- test-programs/Cargo.toml | 2 + test-programs/src/lib.rs | 56 +++++++++++++++++++ test-programs/tests/axum_hello_world.rs | 37 ++----------- test-programs/tests/axum_weather.rs | 54 ++++++++++++++++++ test-programs/tests/http_server.rs | 73 +++++++++---------------- 7 files changed, 149 insertions(+), 83 deletions(-) create mode 100644 test-programs/tests/axum_weather.rs diff --git a/Cargo.toml b/Cargo.toml index 408df4c..ee1a87d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,7 +95,7 @@ syn = "2.0" test-log = { version = "0.2", features = ["trace"] } test-programs = { path = "test-programs" } tower-service = "0.3.3" -ureq = { version = "2.12.1", default-features = false } +ureq = { version = "3.1", default-features = false, features = ["json"] } wasip2 = "1.0" wstd = { path = ".", version = "=0.5.6" } wstd-axum = { path = "./axum", version = "=0.5.6" } diff --git a/examples/http_server.rs b/examples/http_server.rs index c37e861..8449f83 100644 --- a/examples/http_server.rs +++ b/examples/http_server.rs @@ -122,9 +122,11 @@ async fn http_response_status(request: Request) -> Result> } else { 500 }; - Ok(Response::builder() - .status(status) - .body(String::new().into())?) + let mut response = Response::builder().status(status); + if status == 302 { + response = response.header("Location", "http://localhost/response-status"); + } + Ok(response.body(String::new().into())?) } async fn http_response_fail(_request: Request) -> Result> { diff --git a/test-programs/Cargo.toml b/test-programs/Cargo.toml index bd81505..24844b7 100644 --- a/test-programs/Cargo.toml +++ b/test-programs/Cargo.toml @@ -7,10 +7,12 @@ rust-version.workspace = true publish = false [dependencies] +fslock = "0.2.1" [dev-dependencies] anyhow.workspace = true test-log.workspace = true +serde_json.workspace = true ureq.workspace = true [build-dependencies] diff --git a/test-programs/src/lib.rs b/test-programs/src/lib.rs index 26a930a..e7d9289 100644 --- a/test-programs/src/lib.rs +++ b/test-programs/src/lib.rs @@ -1 +1,57 @@ include!(concat!(env!("OUT_DIR"), "/gen.rs")); + +use fslock::LockFile; +use std::net::TcpStream; +use std::process::{Child, Command}; +use std::thread::sleep; +use std::time::Duration; + +/// Manages exclusive access to port 8081, and kills the process when dropped +pub struct WasmtimeServe { + #[expect(dead_code, reason = "exists to live for as long as wasmtime process")] + lockfile: LockFile, + process: Child, +} + +impl WasmtimeServe { + /// Run `wasmtime serve -Scli --addr=127.0.0.1:8081` for a given wasm + /// guest filepath. + /// + /// Takes exclusive access to a lockfile so that only one test on a host + /// can use port 8081 at a time. + /// + /// Kills the wasmtime process, and releases the lock, once dropped. + pub fn new(guest: &str) -> std::io::Result { + let mut lockfile = std::env::temp_dir(); + lockfile.push("TEST_PROGRAMS_WASMTIME_SERVE.pid"); + let mut lockfile = LockFile::open(&lockfile)?; + lockfile.lock_with_pid()?; + + // Run wasmtime serve. + // Enable -Scli because we currently don't have a way to build with the + // proxy adapter, so we build with the default adapter. + let process = Command::new("wasmtime") + .arg("serve") + .arg("-Scli") + .arg("--addr=127.0.0.1:8081") + .arg(guest) + .spawn()?; + let w = WasmtimeServe { lockfile, process }; + + // Clumsily wait for the server to accept connections. + 'wait: loop { + sleep(Duration::from_millis(100)); + if TcpStream::connect("127.0.0.1:8081").is_ok() { + break 'wait; + } + } + Ok(w) + } +} +// Wasmtime serve will run until killed. Kill it in a drop impl so the process +// isnt orphaned when the test suite ends (successfully, or unsuccessfully) +impl Drop for WasmtimeServe { + fn drop(&mut self) { + let _ = self.process.kill(); + } +} diff --git a/test-programs/tests/axum_hello_world.rs b/test-programs/tests/axum_hello_world.rs index 1a33956..39c782f 100644 --- a/test-programs/tests/axum_hello_world.rs +++ b/test-programs/tests/axum_hello_world.rs @@ -1,17 +1,4 @@ use anyhow::Result; -use std::net::TcpStream; -use std::process::{Child, Command}; -use std::thread::sleep; -use std::time::Duration; - -// Wasmtime serve will run until killed. Kill it in a drop impl so the process -// isnt orphaned when the test suite ends (successfully, or unsuccessfully) -struct DontOrphan(Child); -impl Drop for DontOrphan { - fn drop(&mut self) { - let _ = self.0.kill(); - } -} #[test_log::test] fn hello_world() -> Result<()> { @@ -27,30 +14,16 @@ fn hello_world_nomacro() -> Result<()> { // functionality fn run(guest: &str) -> Result<()> { // Run wasmtime serve. - // Enable -Scli because we currently don't have a way to build with the - // proxy adapter, so we build with the default adapter. - let _wasmtime_process = DontOrphan( - Command::new("wasmtime") - .arg("serve") - .arg("-Scli") - .arg("--addr=127.0.0.1:8081") - .arg(guest) - .spawn()?, - ); - - // Clumsily wait for the server to accept connections. - 'wait: loop { - sleep(Duration::from_millis(100)); - if TcpStream::connect("127.0.0.1:8081").is_ok() { - break 'wait; - } - } + let _serve = test_programs::WasmtimeServe::new(guest)?; // Test each path in the server: // TEST / handler // Response body is the hard-coded default - let body: String = ureq::get("http://127.0.0.1:8081").call()?.into_string()?; + let body: String = ureq::get("http://127.0.0.1:8081") + .call()? + .body_mut() + .read_to_string()?; assert!(body.contains("

Hello, World!

")); Ok(()) diff --git a/test-programs/tests/axum_weather.rs b/test-programs/tests/axum_weather.rs new file mode 100644 index 0000000..4149d0c --- /dev/null +++ b/test-programs/tests/axum_weather.rs @@ -0,0 +1,54 @@ +use anyhow::Result; +use serde_json::Value; + +const CITY: &str = "portland"; +const COUNT: usize = 2; + +#[test_log::test] +fn weather() -> Result<()> { + // Run wasmtime serve. + let _serve = test_programs::WasmtimeServe::new(test_programs::axum::WEATHER)?; + + // TEST /weather weather handler + let body = ureq::get(format!( + "http://127.0.0.1:8081/weather?city={CITY}&count={COUNT}" + )) + .call()? + .body_mut() + .read_json::()?; + let array = body.as_array().expect("json body is an array"); + assert_eq!(array.len(), COUNT); + let item_0 = &array[0]; + let loc_0 = item_0 + .get("location") + .expect("item 0 has `location`") + .as_object() + .expect("location 0 is object"); + let qn_0 = loc_0 + .get("qualified_name") + .expect("location has qualified name") + .as_str() + .expect("name is string"); + assert!( + qn_0.contains("Multnomah"), + "{qn_0:?} should contain substring 'Multnomah'" + ); + + let item_1 = &array[1]; + let loc_1 = item_1 + .get("location") + .expect("item 1 has `location`") + .as_object() + .expect("location 1 is object"); + let qn_1 = loc_1 + .get("qualified_name") + .expect("location has qualified name") + .as_str() + .expect("name is string"); + assert!( + qn_1.contains("Cumberland"), + "{qn_1:?} should contain substring 'Cumberland'" + ); + + Ok(()) +} diff --git a/test-programs/tests/http_server.rs b/test-programs/tests/http_server.rs index df96392..a8e9ae9 100644 --- a/test-programs/tests/http_server.rs +++ b/test-programs/tests/http_server.rs @@ -1,45 +1,21 @@ use anyhow::Result; -use std::net::TcpStream; -use std::process::{Child, Command}; -use std::thread::sleep; use std::time::{Duration, Instant}; -// Wasmtime serve will run until killed. Kill it in a drop impl so the process -// isnt orphaned when the test suite ends (successfully, or unsuccessfully) -struct DontOrphan(Child); -impl Drop for DontOrphan { - fn drop(&mut self) { - let _ = self.0.kill(); - } -} - #[test_log::test] fn http_server() -> Result<()> { // Run wasmtime serve. // Enable -Scli because we currently don't have a way to build with the // proxy adapter, so we build with the default adapter. - let _wasmtime_process = DontOrphan( - Command::new("wasmtime") - .arg("serve") - .arg("-Scli") - .arg("--addr=127.0.0.1:8081") - .arg(test_programs::HTTP_SERVER) - .spawn()?, - ); - - // Clumsily wait for the server to accept connections. - 'wait: loop { - sleep(Duration::from_millis(100)); - if TcpStream::connect("127.0.0.1:8081").is_ok() { - break 'wait; - } - } + let _serve = test_programs::WasmtimeServe::new(test_programs::HTTP_SERVER)?; // Test each path in the server: // TEST / http_home // Response body is the hard-coded default - let body: String = ureq::get("http://127.0.0.1:8081").call()?.into_string()?; + let body: String = ureq::get("http://127.0.0.1:8081") + .call()? + .body_mut() + .read_to_string()?; assert_eq!(body, "Hello, wasi:http/proxy world!\n"); // TEST /wait-response http_wait_response @@ -48,7 +24,8 @@ fn http_server() -> Result<()> { let start = Instant::now(); let body: String = ureq::get("http://127.0.0.1:8081/wait-response") .call()? - .into_string()?; + .body_mut() + .read_to_string()?; let duration = start.elapsed(); let sleep_report = body .split(' ') @@ -69,7 +46,8 @@ fn http_server() -> Result<()> { let start = Instant::now(); let body: String = ureq::get("http://127.0.0.1:8081/wait-body") .call()? - .into_string()?; + .body_mut() + .read_to_string()?; let duration = start.elapsed(); let sleep_report = body .split(' ') @@ -91,7 +69,8 @@ fn http_server() -> Result<()> { let start = Instant::now(); let body: String = ureq::get("http://127.0.0.1:8081/stream-body") .call()? - .into_string()?; + .body_mut() + .read_to_string()?; let duration = start.elapsed(); assert_eq!(body.lines().count(), 5, "body has 5 lines"); for (iter, line) in body.lines().enumerate() { @@ -110,8 +89,10 @@ fn http_server() -> Result<()> { // Send a request body, see that we got the same back in response body. const MESSAGE: &[u8] = b"hello, echoserver!\n"; let body: String = ureq::get("http://127.0.0.1:8081/echo") + .force_send_body() .send(MESSAGE)? - .into_string()?; + .body_mut() + .read_to_string()?; assert_eq!(body.as_bytes(), MESSAGE); // TEST /echo-headers htto_echo_headers @@ -127,12 +108,15 @@ fn http_server() -> Result<()> { ]; let mut request = ureq::get("http://127.0.0.1:8081/echo-headers"); for (name, value) in test_headers { - request = request.set(name, value); + request = request.header(name, value); } let response = request.call()?; - assert!(response.headers_names().len() >= test_headers.len()); + assert!(response.headers().len() >= test_headers.len()); for (name, value) in test_headers { - assert_eq!(response.header(name), Some(value)); + assert_eq!( + response.headers().get(name), + Some(&ureq::http::HeaderValue::from_str(value).unwrap()) + ); } // NOT TESTED /echo-trailers htto_echo_trailers @@ -142,16 +126,11 @@ fn http_server() -> Result<()> { // Send request with `X-Request-Code: `. Should get back that // status. let response = ureq::get("http://127.0.0.1:8081/response-status") - .set("X-Response-Status", "302") - .call()?; - assert_eq!(response.status(), 302); - - let response = ureq::get("http://127.0.0.1:8081/response-status") - .set("X-Response-Status", "401") + .header("X-Response-Status", "401") .call(); - // ureq interprets some statuses as OK, some as Err: + // ureq gives us a 401 in an Error::StatusCode match response { - Err(ureq::Error::Status(401, _)) => {} + Err(ureq::Error::StatusCode(401)) => {} result => { panic!("/response-code expected status 302, got: {result:?}"); } @@ -161,7 +140,7 @@ fn http_server() -> Result<()> { // Wasmtime gives a 500 error when wasi-http guest gives error instead of // response match ureq::get("http://127.0.0.1:8081/response-fail").call() { - Err(ureq::Error::Status(500, _)) => {} + Err(ureq::Error::StatusCode(500)) => {} result => { panic!("/response-fail expected status 500 error, got: {result:?}"); } @@ -171,9 +150,9 @@ fn http_server() -> Result<()> { // Response status and headers sent off, then error in body will close // connection match ureq::get("http://127.0.0.1:8081/response-body-fail").call() { - Err(ureq::Error::Transport(_transport)) => {} + Err(ureq::Error::Io(_transport)) => {} result => { - panic!("/response-body-fail expected transport error, got: {result:?}") + panic!("/response-body-fail expected io error, got: {result:?}") } } From de99ea4afc0b615a6108851131c494b8c07a4568 Mon Sep 17 00:00:00 2001 From: Pat Hickey Date: Thu, 23 Oct 2025 10:47:38 -0700 Subject: [PATCH 10/11] file locking is in std if we lie about msrv policy for 7 days --- Cargo.toml | 2 +- src/net/tcp_listener.rs | 2 +- test-programs/Cargo.toml | 3 --- test-programs/src/lib.rs | 10 +++++----- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ee1a87d..2a362d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,7 @@ repository = "https://github.com/bytecodealliance/wstd" keywords = ["WebAssembly", "async", "stdlib", "Components"] categories = ["wasm", "asynchronous"] # Rust-version policy: stable N-2, same as wasmtime. -rust-version = "1.87" +rust-version = "1.89" authors = [ "Yoshua Wuyts ", "Pat Hickey ", diff --git a/src/net/tcp_listener.rs b/src/net/tcp_listener.rs index 9f6d67f..9a1f57a 100644 --- a/src/net/tcp_listener.rs +++ b/src/net/tcp_listener.rs @@ -5,7 +5,7 @@ use crate::io; use crate::iter::AsyncIterator; use std::net::SocketAddr; -use super::{to_io_err, TcpStream}; +use super::{TcpStream, to_io_err}; use crate::runtime::AsyncPollable; /// A TCP socket server, listening for connections. diff --git a/test-programs/Cargo.toml b/test-programs/Cargo.toml index 24844b7..7f6191e 100644 --- a/test-programs/Cargo.toml +++ b/test-programs/Cargo.toml @@ -6,9 +6,6 @@ license.workspace = true rust-version.workspace = true publish = false -[dependencies] -fslock = "0.2.1" - [dev-dependencies] anyhow.workspace = true test-log.workspace = true diff --git a/test-programs/src/lib.rs b/test-programs/src/lib.rs index e7d9289..86e6d9f 100644 --- a/test-programs/src/lib.rs +++ b/test-programs/src/lib.rs @@ -1,6 +1,6 @@ include!(concat!(env!("OUT_DIR"), "/gen.rs")); -use fslock::LockFile; +use std::fs::File; use std::net::TcpStream; use std::process::{Child, Command}; use std::thread::sleep; @@ -9,7 +9,7 @@ use std::time::Duration; /// Manages exclusive access to port 8081, and kills the process when dropped pub struct WasmtimeServe { #[expect(dead_code, reason = "exists to live for as long as wasmtime process")] - lockfile: LockFile, + lockfile: File, process: Child, } @@ -23,9 +23,9 @@ impl WasmtimeServe { /// Kills the wasmtime process, and releases the lock, once dropped. pub fn new(guest: &str) -> std::io::Result { let mut lockfile = std::env::temp_dir(); - lockfile.push("TEST_PROGRAMS_WASMTIME_SERVE.pid"); - let mut lockfile = LockFile::open(&lockfile)?; - lockfile.lock_with_pid()?; + lockfile.push("TEST_PROGRAMS_WASMTIME_SERVE.lock"); + let lockfile = File::create(&lockfile)?; + lockfile.lock()?; // Run wasmtime serve. // Enable -Scli because we currently don't have a way to build with the From 3bbd1f2df02da044fd5b77794beb8275476b3f1e Mon Sep 17 00:00:00 2001 From: Pat Hickey Date: Thu, 23 Oct 2025 10:58:47 -0700 Subject: [PATCH 11/11] clippy for new msrv --- src/io/copy.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/io/copy.rs b/src/io/copy.rs index 6aa1aff..4fd178e 100644 --- a/src/io/copy.rs +++ b/src/io/copy.rs @@ -8,11 +8,11 @@ where { // Optimized path when we have an `AsyncInputStream` and an // `AsyncOutputStream`. - if let Some(reader) = reader.as_async_input_stream() { - if let Some(writer) = writer.as_async_output_stream() { - reader.copy_to(writer).await?; - return Ok(()); - } + if let Some(reader) = reader.as_async_input_stream() + && let Some(writer) = writer.as_async_output_stream() + { + reader.copy_to(writer).await?; + return Ok(()); } // Unoptimized case: read the input and then write it.