Skip to content

Commit 779aeed

Browse files
authored
initial commit of http-client component (#69)
Signed-off-by: markfisher <mark@modulewise.com>
1 parent bd2994d commit 779aeed

13 files changed

Lines changed: 1820 additions & 0 deletions

File tree

components/Cargo.lock

Lines changed: 611 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[workspace]
2+
members = [
3+
"http/client",
4+
]
5+
resolver = "3"
6+
7+
[workspace.dependencies]
8+
wit-bindgen = "0.57"

components/build.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/bin/sh
2+
3+
PROJECTS=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[].name')
4+
5+
for project in $PROJECTS; do
6+
echo "Building $project..."
7+
8+
target=wasm32-unknown-unknown
9+
cargo build -p "$project" --target $target --release
10+
11+
cargo_name=$(echo "$project" | tr '-' '_')
12+
core_wasm="target/${target}/release/${cargo_name}.wasm"
13+
wasm-tools component new "$core_wasm" -o "lib/${project}.wasm"
14+
echo " -> lib/${project}.wasm"
15+
done

components/http/client/Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[package]
2+
name = "http-client"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[lib]
7+
crate-type = ["cdylib"]
8+
9+
[dependencies]
10+
url = "2.5"
11+
wit-bindgen = { workspace = true }

components/http/client/src/lib.rs

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
use url::Url;
2+
use wasi::http::types::{
3+
Headers, IncomingBody, Method as WasiMethod, OutgoingBody, OutgoingRequest,
4+
RequestOptions as WasiRequestOptions, Scheme,
5+
};
6+
use wasi::io::streams::StreamError;
7+
8+
wit_bindgen::generate!({
9+
path: "../wit",
10+
generate_all
11+
});
12+
13+
use exports::composable::http::client::{Guest, HttpResponse, Method, RequestOptions};
14+
15+
struct HttpClient;
16+
17+
impl HttpClient {
18+
fn request(
19+
method: &WasiMethod,
20+
url: &str,
21+
headers: Vec<(String, String)>,
22+
body: Option<Vec<u8>>,
23+
options: Option<RequestOptions>,
24+
) -> Result<HttpResponse, String> {
25+
let request_headers = Headers::new();
26+
for (name, value) in headers {
27+
request_headers
28+
.append(&name, value.as_bytes())
29+
.map_err(|e| format!("Failed to set header {name}: {e:?}"))?;
30+
}
31+
32+
let parsed = Url::parse(url).map_err(|e| format!("Invalid URL: {e}"))?;
33+
let scheme = match parsed.scheme() {
34+
"http" => Scheme::Http,
35+
"https" => Scheme::Https,
36+
other => return Err(format!("Unsupported URL scheme: {other}")),
37+
};
38+
let host = parsed
39+
.host_str()
40+
.ok_or_else(|| "URL is missing a host".to_string())?;
41+
let authority = match parsed.port() {
42+
Some(port) => format!("{host}:{port}"),
43+
None => host.to_string(),
44+
};
45+
let path_with_query = match parsed.query() {
46+
Some(q) => format!("{}?{q}", parsed.path()),
47+
None => parsed.path().to_string(),
48+
};
49+
50+
let request = OutgoingRequest::new(request_headers);
51+
request
52+
.set_method(method)
53+
.map_err(|()| "Failed to set request method".to_string())?;
54+
request
55+
.set_scheme(Some(&scheme))
56+
.map_err(|()| "Failed to set request scheme".to_string())?;
57+
request
58+
.set_authority(Some(&authority))
59+
.map_err(|()| "Failed to set request authority".to_string())?;
60+
request
61+
.set_path_with_query(Some(&path_with_query))
62+
.map_err(|()| "Failed to set request path".to_string())?;
63+
64+
if let Some(body_data) = body {
65+
let outgoing_body = request
66+
.body()
67+
.map_err(|()| "Failed to get request body".to_string())?;
68+
let output_stream = outgoing_body
69+
.write()
70+
.map_err(|()| "Failed to get output stream".to_string())?;
71+
output_stream
72+
.blocking_write_and_flush(&body_data)
73+
.map_err(|e| format!("Failed to write request body: {e:?}"))?;
74+
drop(output_stream);
75+
OutgoingBody::finish(outgoing_body, None)
76+
.map_err(|e| format!("wasi:http error: {e:?}"))?;
77+
}
78+
79+
let max_response_body_bytes = options.as_ref().and_then(|o| o.max_response_body_bytes);
80+
let wasi_options = options.map(wasi_request_options).transpose()?;
81+
let response = wasi::http::outgoing_handler::handle(request, wasi_options)
82+
.map_err(|_| "Failed to send HTTP request".to_string())?;
83+
response.subscribe().block();
84+
let response = response
85+
.get()
86+
.ok_or("Failed to get response".to_string())?
87+
.map_err(|()| "Request failed".to_string())?
88+
.map_err(|e| format!("wasi:http error: {e:?}"))?;
89+
90+
let status = response.status();
91+
92+
let headers: Vec<(String, String)> = response
93+
.headers()
94+
.entries()
95+
.into_iter()
96+
.map(|(k, v)| (k, v.into_iter().map(|b| b as char).collect()))
97+
.collect();
98+
99+
if let Some(max) = max_response_body_bytes
100+
&& let Some(content_length) = headers
101+
.iter()
102+
.find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
103+
.and_then(|(_, v)| v.parse::<u64>().ok())
104+
&& content_length > max
105+
{
106+
return Err(format!(
107+
"Response Content-Length {content_length} exceeds max size of {max} bytes"
108+
));
109+
}
110+
111+
let body = response
112+
.consume()
113+
.map_err(|()| "Failed to consume response body".to_string())?;
114+
let stream = body
115+
.stream()
116+
.map_err(|()| "Failed to get response stream".to_string())?;
117+
let mut body_bytes = Vec::new();
118+
loop {
119+
match stream.blocking_read(8192) {
120+
Ok(chunk) if chunk.is_empty() => break,
121+
Ok(chunk) => {
122+
body_bytes.extend_from_slice(&chunk);
123+
if let Some(max) = max_response_body_bytes
124+
&& body_bytes.len() as u64 > max
125+
{
126+
return Err(format!("Response body exceeded max size of {max} bytes"));
127+
}
128+
}
129+
Err(StreamError::Closed) => break,
130+
Err(StreamError::LastOperationFailed(io_error)) => {
131+
let detail = wasi::http::types::http_error_code(&io_error)
132+
.map(|code| format!("wasi:http error: {code:?}"))
133+
.unwrap_or_else(|| format!("stream error: {io_error:?}"));
134+
return Err(format!("Failed to read response body: {detail}"));
135+
}
136+
}
137+
}
138+
drop(stream);
139+
140+
let trailers = IncomingBody::finish(body);
141+
trailers.subscribe().block();
142+
let trailers: Vec<(String, String)> = trailers
143+
.get()
144+
.ok_or("Failed to get trailers".to_string())?
145+
.map_err(|()| "Trailers already consumed".to_string())?
146+
.map_err(|e| format!("wasi:http error: {e:?}"))?
147+
.map(|t| {
148+
t.entries()
149+
.into_iter()
150+
.map(|(k, v)| (k, v.into_iter().map(|b| b as char).collect()))
151+
.collect()
152+
})
153+
.unwrap_or_default();
154+
155+
Ok(HttpResponse {
156+
status,
157+
headers,
158+
body: body_bytes,
159+
trailers,
160+
})
161+
}
162+
}
163+
164+
fn wasi_request_options(opts: RequestOptions) -> Result<WasiRequestOptions, String> {
165+
let r = WasiRequestOptions::new();
166+
if let Some(ms) = opts.connect_timeout_ms {
167+
r.set_connect_timeout(Some(ms_to_ns(ms)))
168+
.map_err(|()| "connect-timeout not supported by host".to_string())?;
169+
}
170+
if let Some(ms) = opts.first_byte_timeout_ms {
171+
r.set_first_byte_timeout(Some(ms_to_ns(ms)))
172+
.map_err(|()| "first-byte-timeout not supported by host".to_string())?;
173+
}
174+
if let Some(ms) = opts.between_bytes_timeout_ms {
175+
r.set_between_bytes_timeout(Some(ms_to_ns(ms)))
176+
.map_err(|()| "between-bytes-timeout not supported by host".to_string())?;
177+
}
178+
Ok(r)
179+
}
180+
181+
fn ms_to_ns(ms: u32) -> u64 {
182+
u64::from(ms) * 1_000_000
183+
}
184+
185+
impl Guest for HttpClient {
186+
fn request(
187+
method: Method,
188+
url: String,
189+
headers: Vec<(String, String)>,
190+
body: Vec<u8>,
191+
options: Option<RequestOptions>,
192+
) -> Result<HttpResponse, String> {
193+
let wasi_method = match method {
194+
Method::Get => WasiMethod::Get,
195+
Method::Post => WasiMethod::Post,
196+
Method::Put => WasiMethod::Put,
197+
Method::Delete => WasiMethod::Delete,
198+
Method::Patch => WasiMethod::Patch,
199+
Method::Head => WasiMethod::Head,
200+
Method::Options => WasiMethod::Options,
201+
};
202+
let body = if body.is_empty() { None } else { Some(body) };
203+
Self::request(&wasi_method, &url, headers, body, options)
204+
}
205+
206+
fn get(
207+
url: String,
208+
headers: Vec<(String, String)>,
209+
options: Option<RequestOptions>,
210+
) -> Result<HttpResponse, String> {
211+
Self::request(&WasiMethod::Get, &url, headers, None, options)
212+
}
213+
214+
fn post(
215+
url: String,
216+
headers: Vec<(String, String)>,
217+
body: Vec<u8>,
218+
options: Option<RequestOptions>,
219+
) -> Result<HttpResponse, String> {
220+
Self::request(&WasiMethod::Post, &url, headers, Some(body), options)
221+
}
222+
223+
fn put(
224+
url: String,
225+
headers: Vec<(String, String)>,
226+
body: Vec<u8>,
227+
options: Option<RequestOptions>,
228+
) -> Result<HttpResponse, String> {
229+
Self::request(&WasiMethod::Put, &url, headers, Some(body), options)
230+
}
231+
232+
fn delete(
233+
url: String,
234+
headers: Vec<(String, String)>,
235+
options: Option<RequestOptions>,
236+
) -> Result<HttpResponse, String> {
237+
Self::request(&WasiMethod::Delete, &url, headers, None, options)
238+
}
239+
240+
fn patch(
241+
url: String,
242+
headers: Vec<(String, String)>,
243+
body: Vec<u8>,
244+
options: Option<RequestOptions>,
245+
) -> Result<HttpResponse, String> {
246+
Self::request(&WasiMethod::Patch, &url, headers, Some(body), options)
247+
}
248+
249+
fn head(
250+
url: String,
251+
headers: Vec<(String, String)>,
252+
options: Option<RequestOptions>,
253+
) -> Result<HttpResponse, String> {
254+
Self::request(&WasiMethod::Head, &url, headers, None, options)
255+
}
256+
257+
fn options(
258+
url: String,
259+
headers: Vec<(String, String)>,
260+
options: Option<RequestOptions>,
261+
) -> Result<HttpResponse, String> {
262+
Self::request(&WasiMethod::Options, &url, headers, None, options)
263+
}
264+
}
265+
266+
export!(HttpClient);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package wasi:cli@0.2.6;
2+
3+
interface stdout {
4+
use wasi:io/streams@0.2.6.{output-stream};
5+
6+
get-stdout: func() -> output-stream;
7+
}
8+
9+
interface stderr {
10+
use wasi:io/streams@0.2.6.{output-stream};
11+
12+
get-stderr: func() -> output-stream;
13+
}
14+
15+
interface stdin {
16+
use wasi:io/streams@0.2.6.{input-stream};
17+
18+
get-stdin: func() -> input-stream;
19+
}
20+
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package wasi:clocks@0.2.6;
2+
3+
interface monotonic-clock {
4+
use wasi:io/poll@0.2.6.{pollable};
5+
6+
type instant = u64;
7+
8+
type duration = u64;
9+
10+
now: func() -> instant;
11+
12+
resolution: func() -> duration;
13+
14+
subscribe-instant: func(when: instant) -> pollable;
15+
16+
subscribe-duration: func(when: duration) -> pollable;
17+
}
18+
19+
interface wall-clock {
20+
record datetime {
21+
seconds: u64,
22+
nanoseconds: u32,
23+
}
24+
25+
now: func() -> datetime;
26+
27+
resolution: func() -> datetime;
28+
}
29+

0 commit comments

Comments
 (0)