From db2b734f8990117bb4d515e466394f47ee163610 Mon Sep 17 00:00:00 2001 From: Xinzhao Xu Date: Sat, 11 May 2024 15:50:56 +0800 Subject: [PATCH] Initial version of wasi-http-client --- .github/workflows/ci.yml | 22 ++++++++ .github/workflows/release.yml | 20 +++++++ Cargo.toml | 16 ++++++ src/client.rs | 39 +++++++++++++ src/lib.rs | 7 +++ src/request.rs | 102 ++++++++++++++++++++++++++++++++++ src/response.rs | 60 ++++++++++++++++++++ 7 files changed, 266 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 Cargo.toml create mode 100644 src/client.rs create mode 100644 src/lib.rs create mode 100644 src/request.rs create mode 100644 src/response.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..47747e9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI +on: + push: + branches: + - 'main' + - 'release-**' + pull_request: +jobs: + ci: + name: Lint and test + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@1.78.0 + with: + components: clippy, rustfmt + - name: cargo fmt + run: cargo fmt --all -- --check + - name: cargo clippy + run: cargo clippy --all-targets --all-features -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e879983 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,20 @@ +name: release +on: + push: + tags: + - "v*" +permissions: + contents: write +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Install Rust + uses: dtolnay/rust-toolchain@1.78.0 + - name: cargo publish + run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..713ab40 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "wasi-http-client" +description = "HTTP client library for WASI" +readme = "README.md" +version = "0.1.0" +edition = "2021" +authors = ["Xinzhao Xu"] +categories = ["wasm"] +keywords = ["webassembly", "wasm", "wasi"] +repository = "https://github.com/wacker-dev/wasi-http-client" +license = "Apache-2.0" + +[dependencies] +anyhow = "1.0.83" +wasi = "0.13.0" +url = "2.5.0" diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..6db0f27 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,39 @@ +use crate::RequestBuilder; +use wasi::http::types::Method; + +#[derive(Default)] +pub struct Client {} + +impl Client { + pub fn new() -> Self { + Default::default() + } + + pub fn get(&self, url: &str) -> RequestBuilder { + self.request(Method::Get, url) + } + + pub fn post(&self, url: &str) -> RequestBuilder { + self.request(Method::Post, url) + } + + pub fn put(&self, url: &str) -> RequestBuilder { + self.request(Method::Put, url) + } + + pub fn patch(&self, url: &str) -> RequestBuilder { + self.request(Method::Patch, url) + } + + pub fn delete(&self, url: &str) -> RequestBuilder { + self.request(Method::Delete, url) + } + + pub fn head(&self, url: &str) -> RequestBuilder { + self.request(Method::Head, url) + } + + pub fn request(&self, method: Method, url: &str) -> RequestBuilder { + RequestBuilder::new(method, url) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..e2380b3 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +mod client; +mod request; +mod response; + +pub use self::client::Client; +pub use self::request::RequestBuilder; +pub use self::response::Response; diff --git a/src/request.rs b/src/request.rs new file mode 100644 index 0000000..e8c327c --- /dev/null +++ b/src/request.rs @@ -0,0 +1,102 @@ +use crate::Response; +use anyhow::{anyhow, Result}; +use std::time::Duration; +use url::Url; +use wasi::http::{ + outgoing_handler, + types::{FieldValue, Headers, Method, OutgoingBody, OutgoingRequest, RequestOptions, Scheme}, +}; + +pub struct RequestBuilder { + method: Method, + url: String, + headers: Headers, + body: Vec, + connect_timeout: Option, +} + +impl RequestBuilder { + pub fn new(method: Method, url: &str) -> Self { + Self { + method, + url: url.to_string(), + headers: Headers::new(), + body: vec![], + connect_timeout: None, + } + } + + pub fn header(self, key: &str, value: &str) -> Result { + self.headers + .set(&key.to_string(), &[FieldValue::from(value)])?; + Ok(self) + } + + pub fn body(mut self, body: &[u8]) -> Self { + self.body = Vec::from(body); + self + } + + pub fn connect_timeout(mut self, timeout: Duration) -> Self { + self.connect_timeout = Some(timeout.as_nanos() as u64); + self + } + + pub fn send(self) -> Result { + let req = OutgoingRequest::new(self.headers); + req.set_method(&self.method) + .map_err(|()| anyhow!("failed to set method"))?; + + let url = Url::parse(self.url.as_str())?; + let scheme = match url.scheme() { + "http" => Scheme::Http, + "https" => Scheme::Https, + other => Scheme::Other(other.to_string()), + }; + req.set_scheme(Some(&scheme)) + .map_err(|()| anyhow!("failed to set scheme"))?; + + req.set_authority(Some(url.authority())) + .map_err(|()| anyhow!("failed to set authority"))?; + + let path = match url.query() { + Some(query) => format!("{}?{query}", url.path()), + None => url.path().to_string(), + }; + req.set_path_with_query(Some(&path)) + .map_err(|()| anyhow!("failed to set path_with_query"))?; + + let outgoing_body = req + .body() + .map_err(|_| anyhow!("outgoing request write failed"))?; + if !self.body.is_empty() { + let request_body = outgoing_body + .write() + .map_err(|_| anyhow!("outgoing request write failed"))?; + request_body.blocking_write_and_flush(&self.body)?; + } + OutgoingBody::finish(outgoing_body, None)?; + + let options = RequestOptions::new(); + options + .set_connect_timeout(self.connect_timeout) + .map_err(|()| anyhow!("failed to set connect_timeout"))?; + + let future_response = outgoing_handler::handle(req, Some(options))?; + let incoming_response = match future_response.get() { + Some(result) => result.map_err(|()| anyhow!("response already taken"))?, + None => { + let pollable = future_response.subscribe(); + pollable.block(); + + future_response + .get() + .expect("incoming response available") + .map_err(|()| anyhow!("response already taken"))? + } + }?; + drop(future_response); + + Response::new(incoming_response) + } +} diff --git a/src/response.rs b/src/response.rs new file mode 100644 index 0000000..03b2b7c --- /dev/null +++ b/src/response.rs @@ -0,0 +1,60 @@ +use anyhow::{anyhow, Result}; +use std::collections::HashMap; +use wasi::http::types::{IncomingResponse, StatusCode}; +use wasi::io::streams::StreamError; + +pub struct Response { + status: StatusCode, + headers: HashMap, + body: Vec, +} + +impl Response { + pub fn new(incoming_response: IncomingResponse) -> Result { + let status = incoming_response.status(); + + let mut headers: HashMap = HashMap::new(); + let headers_handle = incoming_response.headers(); + for (key, value) in headers_handle.entries() { + headers.insert(key, String::from_utf8(value)?); + } + drop(headers_handle); + + let incoming_body = incoming_response + .consume() + .map_err(|()| anyhow!("incoming response has no body stream"))?; + drop(incoming_response); + + let input_stream = incoming_body.stream().unwrap(); + let mut body = vec![]; + loop { + let mut body_chunk = match input_stream.read(1024 * 1024) { + Ok(c) => c, + Err(StreamError::Closed) => break, + Err(e) => Err(anyhow!("input_stream read failed: {e:?}"))?, + }; + + if !body_chunk.is_empty() { + body.append(&mut body_chunk); + } + } + + Ok(Self { + status, + headers, + body, + }) + } + + pub fn status(&self) -> &StatusCode { + &self.status + } + + pub fn headers(&self) -> &HashMap { + &self.headers + } + + pub fn body(&self) -> &Vec { + &self.body + } +}