From db2b734f8990117bb4d515e466394f47ee163610 Mon Sep 17 00:00:00 2001
From: Xinzhao Xu <z2d@jifangcheng.com>
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<u8>,
+    connect_timeout: Option<u64>,
+}
+
+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> {
+        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<Response> {
+        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<String, String>,
+    body: Vec<u8>,
+}
+
+impl Response {
+    pub fn new(incoming_response: IncomingResponse) -> Result<Self> {
+        let status = incoming_response.status();
+
+        let mut headers: HashMap<String, String> = 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<String, String> {
+        &self.headers
+    }
+
+    pub fn body(&self) -> &Vec<u8> {
+        &self.body
+    }
+}