Skip to content
This repository was archived by the owner on Jun 7, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
16 changes: 16 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
39 changes: 39 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 7 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
102 changes: 102 additions & 0 deletions src/request.rs
Original file line number Diff line number Diff line change
@@ -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)
}
}
60 changes: 60 additions & 0 deletions src/response.rs
Original file line number Diff line number Diff line change
@@ -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
}
}