diff --git a/Cargo.toml b/Cargo.toml index 33b9d39..f132298 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,8 +16,8 @@ authors = [ [features] [dependencies] +http.workspace = true slab.workspace = true -url.workspace = true wasi.workspace = true wstd-macro.workspace = true @@ -46,13 +46,13 @@ license = "MIT OR Apache-2.0 OR Apache-2.0 WITH LLVM-exception" anyhow = "1" cargo_metadata = "0.18.1" heck = "0.5" +http = "1.1" quote = "1.0" serde_json = "1" slab = "0.4.9" syn = "2.0" test-programs = { path = "test-programs" } test-programs-artifacts = { path = "test-programs/artifacts" } -url = "2.5.0" wasi = "0.13.1" wasmtime = "26" wasmtime-wasi = "26" diff --git a/examples/http_get.rs b/examples/http_get.rs index c366e66..a54274c 100644 --- a/examples/http_get.rs +++ b/examples/http_get.rs @@ -1,18 +1,21 @@ use std::error::Error; -use wstd::http::{Client, Method, Request, Url}; +use wstd::http::{Client, HeaderValue, Method, Request}; use wstd::io::AsyncRead; #[wstd::main] async fn main() -> Result<(), Box> { - let request = Request::new(Method::Get, Url::parse("https://postman-echo.com/get")?); + let mut request = Request::new(Method::GET, "https://postman-echo.com/get".parse()?); + request + .headers_mut() + .insert("my-header", HeaderValue::from_str("my-value")?); + let mut response = Client::new().send(request).await?; let content_type = response .headers() - .get(&"content-type".into()) - .ok_or_else(|| "response expected to have content-type header")?; - assert_eq!(content_type.len(), 1, "one header value for content-type"); - assert_eq!(content_type[0], b"application/json; charset=utf-8"); + .get("Content-Type") + .ok_or_else(|| "response expected to have Content-Type header")?; + assert_eq!(content_type, "application/json; charset=utf-8"); // Would much prefer read_to_end here: let mut body_buf = vec![0; 4096]; @@ -30,5 +33,15 @@ async fn main() -> Result<(), Box> { "expected body url to contain the authority and path, got: {body_url}" ); + assert_eq!( + val.get("headers") + .ok_or_else(|| "body json has headers")? + .get("my-header") + .ok_or_else(|| "headers contains my-header")? + .as_str() + .ok_or_else(|| "my-header is a str")?, + "my-value" + ); + Ok(()) } diff --git a/src/http/client.rs b/src/http/client.rs index c32209f..02f932b 100644 --- a/src/http/client.rs +++ b/src/http/client.rs @@ -18,7 +18,7 @@ impl Client { /// Send an HTTP request. pub async fn send(&self, req: Request) -> Result> { - let (wasi_req, body) = req.into_outgoing(); + let (wasi_req, body) = req.into_outgoing()?; let wasi_body = wasi_req.body().unwrap(); let body_stream = wasi_body.write().unwrap(); diff --git a/src/http/error.rs b/src/http/error.rs index 14f66b0..4e50a22 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -1,5 +1,104 @@ -/// The `http` error type. -pub type Error = wasi::http::types::ErrorCode; +use std::fmt; /// The `http` result type. pub type Result = std::result::Result; + +/// The `http` error type. +pub struct Error { + variant: ErrorVariant, + context: Vec, +} + +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for c in self.context.iter() { + write!(f, "in {c}:\n")?; + } + match &self.variant { + ErrorVariant::WasiHttp(e) => write!(f, "wasi http error: {e:?}"), + ErrorVariant::WasiHeader(e) => write!(f, "wasi header error: {e:?}"), + ErrorVariant::HeaderName(e) => write!(f, "header name error: {e:?}"), + ErrorVariant::HeaderValue(e) => write!(f, "header value error: {e:?}"), + ErrorVariant::Method(e) => write!(f, "method error: {e:?}"), + ErrorVariant::Other(e) => write!(f, "{e}"), + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.variant { + ErrorVariant::WasiHttp(e) => write!(f, "wasi http error: {e}"), + ErrorVariant::WasiHeader(e) => write!(f, "wasi header error: {e}"), + ErrorVariant::HeaderName(e) => write!(f, "header name error: {e}"), + ErrorVariant::HeaderValue(e) => write!(f, "header value error: {e}"), + ErrorVariant::Method(e) => write!(f, "method error: {e}"), + ErrorVariant::Other(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for Error {} + +impl Error { + pub(crate) fn other(s: impl Into) -> Self { + ErrorVariant::Other(s.into()).into() + } + pub(crate) fn context(self, s: impl Into) -> Self { + let mut context = self.context; + context.push(s.into()); + Self { + variant: self.variant, + context, + } + } +} + +impl From for Error { + fn from(variant: ErrorVariant) -> Error { + Error { + variant, + context: Vec::new(), + } + } +} + +impl From for Error { + fn from(e: wasi::http::types::ErrorCode) -> Error { + ErrorVariant::WasiHttp(e).into() + } +} + +impl From for Error { + fn from(e: wasi::http::types::HeaderError) -> Error { + ErrorVariant::WasiHeader(e).into() + } +} + +impl From for Error { + fn from(e: http::header::InvalidHeaderValue) -> Error { + ErrorVariant::HeaderValue(e).into() + } +} + +impl From for Error { + fn from(e: http::header::InvalidHeaderName) -> Error { + ErrorVariant::HeaderName(e).into() + } +} + +impl From for Error { + fn from(e: http::method::InvalidMethod) -> Error { + ErrorVariant::Method(e).into() + } +} + +#[derive(Debug)] +pub enum ErrorVariant { + WasiHttp(wasi::http::types::ErrorCode), + WasiHeader(wasi::http::types::HeaderError), + HeaderName(http::header::InvalidHeaderName), + HeaderValue(http::header::InvalidHeaderValue), + Method(http::method::InvalidMethod), + Other(String), +} diff --git a/src/http/fields.rs b/src/http/fields.rs index 71c1dc9..22f7093 100644 --- a/src/http/fields.rs +++ b/src/http/fields.rs @@ -1,75 +1,26 @@ -use std::{borrow::Cow, collections::HashMap, ops::Deref}; -use wasi::http::types::{Fields as WasiFields, HeaderError}; - -/// A type alias for [`Fields`] when used as HTTP headers. -pub type Headers = Fields; - -/// A type alias for [`Fields`] when used as HTTP trailers. -pub type Trailers = Fields; - -/// An HTTP Field name. -pub type FieldName = Cow<'static, str>; - -/// An HTTP Field value. -pub type FieldValue = Vec; - -/// HTTP Fields which can be used as either trailers or headers. -#[derive(Clone, PartialEq, Eq)] -pub struct Fields(pub(crate) HashMap>); - -impl Fields { - pub fn get(&self, k: &FieldName) -> Option<&[FieldValue]> { - self.0.get(k).map(|f| f.deref()) - } -} - -impl std::fmt::Debug for Fields { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut map = f.debug_map(); - let mut entries: Vec<_> = self.0.iter().collect(); - entries.sort_by_cached_key(|(k, _)| k.to_owned()); - for (key, values) in entries { - match values.len() { - 0 => { - map.entry(key, &""); - } - 1 => { - let value = values.iter().next().unwrap(); - let value = String::from_utf8_lossy(value); - map.entry(key, &value); - } - _ => { - let values: Vec<_> = - values.iter().map(|v| String::from_utf8_lossy(v)).collect(); - map.entry(key, &values); - } - } - } - map.finish() - } -} - -impl From for Fields { - fn from(wasi_fields: WasiFields) -> Self { - let mut output = HashMap::new(); - for (key, value) in wasi_fields.entries() { - let field_name = key.into(); - let field_list: &mut Vec<_> = output.entry(field_name).or_default(); - field_list.push(value); - } - Self(output) +pub use http::header::{HeaderMap, HeaderName, HeaderValue}; + +use super::{Error, Result}; +use wasi::http::types::Fields; + +pub(crate) fn header_map_from_wasi(wasi_fields: Fields) -> Result { + let mut output = HeaderMap::new(); + for (key, value) in wasi_fields.entries() { + let key = HeaderName::from_bytes(key.as_bytes()) + .map_err(|e| Error::from(e).context("header name {key}"))?; + let value = HeaderValue::from_bytes(&value) + .map_err(|e| Error::from(e).context("header value for {key}"))?; + output.append(key, value); } + Ok(output) } -impl TryFrom for WasiFields { - type Error = HeaderError; - fn try_from(fields: Fields) -> Result { - let mut list = Vec::with_capacity(fields.0.capacity()); - for (name, values) in fields.0.into_iter() { - for value in values { - list.push((name.clone().into_owned(), value)); - } - } - Ok(WasiFields::from_list(&list)?) +pub(crate) fn header_map_to_wasi(header_map: &HeaderMap) -> Result { + let wasi_fields = Fields::new(); + for (key, value) in header_map { + wasi_fields + .append(&key.as_str().to_owned(), &value.as_bytes().to_owned()) + .map_err(|e| Error::from(e).context("header named {key}"))?; } + Ok(wasi_fields) } diff --git a/src/http/method.rs b/src/http/method.rs index 7ef1e18..bd7c210 100644 --- a/src/http/method.rs +++ b/src/http/method.rs @@ -1,71 +1,36 @@ use wasi::http::types::Method as WasiMethod; -/// The method for the HTTP request -#[derive(Debug)] -#[non_exhaustive] -pub enum Method { - /// The GET method requests transfer of a current selected representation - /// for the target resource. - Get, - /// The HEAD method is identical to GET except that the server MUST NOT send a message body in - /// the response. - Head, - /// The POST method requests that the target resource process the representation enclosed in - /// the request according to the resource's own specific semantics. - Post, - /// The PUT method requests that the state of the target resource be created or replaced with - /// the state defined by the representation enclosed in the request message payload. - Put, - /// The DELETE method requests that the origin server remove the association between the target - /// resource and its current functionality. - Delete, - /// The CONNECT method requests that the recipient establish a tunnel to the destination origin - /// server identified by the request-target and, if successful, thereafter restrict its - /// behavior to blind forwarding of packets, in both directions, until the tunnel is closed. - Connect, - /// The OPTIONS method requests information about the communication options available for the - /// target resource, at either the origin server or an intervening intermediary. - Options, - /// The TRACE method requests a remote, application-level loop-back of the request message. - Trace, - /// The PATCH method requests that a set of changes described in the request entity be applied - /// to the resource identified by the Request- URI. - /// - Patch, - /// Send a method not covered by this list. - Other(String), -} +use super::Result; +pub use http::Method; -impl From for WasiMethod { - fn from(value: Method) -> Self { - match value { - Method::Get => WasiMethod::Get, - Method::Head => WasiMethod::Head, - Method::Post => WasiMethod::Post, - Method::Put => WasiMethod::Put, - Method::Delete => WasiMethod::Delete, - Method::Connect => WasiMethod::Connect, - Method::Options => WasiMethod::Options, - Method::Trace => WasiMethod::Trace, - Method::Patch => WasiMethod::Patch, - Method::Other(s) => WasiMethod::Other(s), - } +pub(crate) fn to_wasi_method(value: Method) -> WasiMethod { + match value { + Method::GET => WasiMethod::Get, + Method::HEAD => WasiMethod::Head, + Method::POST => WasiMethod::Post, + Method::PUT => WasiMethod::Put, + Method::DELETE => WasiMethod::Delete, + Method::CONNECT => WasiMethod::Connect, + Method::OPTIONS => WasiMethod::Options, + Method::TRACE => WasiMethod::Trace, + Method::PATCH => WasiMethod::Patch, + other => WasiMethod::Other(other.as_str().to_owned()), } } -impl From for Method { - fn from(value: WasiMethod) -> Self { - match value { - WasiMethod::Get => Method::Get, - WasiMethod::Head => Method::Head, - WasiMethod::Post => Method::Post, - WasiMethod::Put => Method::Put, - WasiMethod::Delete => Method::Delete, - WasiMethod::Connect => Method::Connect, - WasiMethod::Options => Method::Options, - WasiMethod::Trace => Method::Trace, - WasiMethod::Patch => Method::Patch, - WasiMethod::Other(s) => Method::Other(s), - } - } +// This will become useful once we support IncomingRequest +#[allow(dead_code)] +pub(crate) fn from_wasi_method(value: WasiMethod) -> Result { + Ok(match value { + WasiMethod::Get => Method::GET, + WasiMethod::Head => Method::HEAD, + WasiMethod::Post => Method::POST, + WasiMethod::Put => Method::PUT, + WasiMethod::Delete => Method::DELETE, + WasiMethod::Connect => Method::CONNECT, + WasiMethod::Options => Method::OPTIONS, + WasiMethod::Trace => Method::TRACE, + WasiMethod::Patch => Method::PATCH, + WasiMethod::Other(s) => Method::from_bytes(s.as_bytes())?, + }) } diff --git a/src/http/mod.rs b/src/http/mod.rs index a9d85de..1de38b2 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1,12 +1,12 @@ //! HTTP networking support - -pub use url::Url; +//! +pub use http::uri::Uri; #[doc(inline)] pub use body::{Body, IntoBody}; pub use client::Client; pub use error::{Error, Result}; -pub use fields::{FieldName, FieldValue, Fields, Headers, Trailers}; +pub use fields::{HeaderMap, HeaderName, HeaderValue}; pub use method::Method; pub use request::Request; pub use response::Response; diff --git a/src/http/request.rs b/src/http/request.rs index f6bd58e..65c469d 100644 --- a/src/http/request.rs +++ b/src/http/request.rs @@ -1,74 +1,96 @@ use crate::io::{empty, Empty}; -use super::{Body, IntoBody, Method}; -use url::Url; +use super::{ + fields::header_map_to_wasi, method::to_wasi_method, Body, Error, HeaderMap, IntoBody, Method, + Result, +}; +use http::uri::Uri; use wasi::http::outgoing_handler::OutgoingRequest; -use wasi::http::types::{Headers as WasiHeaders, Scheme}; +use wasi::http::types::Scheme; /// An HTTP request #[derive(Debug)] pub struct Request { method: Method, - url: Url, - headers: WasiHeaders, + uri: Uri, + headers: HeaderMap, body: B, } impl Request { /// Create a new HTTP request to send off to the client. - pub fn new(method: Method, url: Url) -> Self { + pub fn new(method: Method, uri: Uri) -> Self { Self { body: empty(), method, - url, - headers: WasiHeaders::new(), + uri, + headers: HeaderMap::new(), } } } impl Request { + /// Get the HTTP headers from the impl + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + /// Mutably get the HTTP headers from the impl + pub fn headers_mut(&mut self) -> &mut HeaderMap { + &mut self.headers + } + /// Set an HTTP body. pub fn set_body(self, body: C) -> Request { let Self { method, - url, + uri, headers, .. } = self; Request { method, - url, + uri, headers, body: body.into_body(), } } - pub fn into_outgoing(self) -> (OutgoingRequest, B) { - let wasi_req = OutgoingRequest::new(self.headers); + pub(crate) fn into_outgoing(self) -> Result<(OutgoingRequest, B)> { + let wasi_req = OutgoingRequest::new(header_map_to_wasi(&self.headers)?); // Set the HTTP method - wasi_req.set_method(&self.method.into()).unwrap(); + let method = to_wasi_method(self.method); + wasi_req + .set_method(&method) + .map_err(|()| Error::other(format!("method rejected by wasi-http: {method:?}",)))?; // Set the url scheme - let scheme = match self.url.scheme() { - "http" => Scheme::Http, - "https" => Scheme::Https, - other => Scheme::Other(other.to_owned()), + let scheme = match self.uri.scheme().map(|s| s.as_str()) { + Some("http") => Scheme::Http, + Some("https") | None => Scheme::Https, + Some(other) => Scheme::Other(other.to_owned()), }; - wasi_req.set_scheme(Some(&scheme)).unwrap(); + wasi_req + .set_scheme(Some(&scheme)) + .map_err(|()| Error::other(format!("scheme rejected by wasi-http: {scheme:?}")))?; - // Set the url path + query string - let path = match self.url.query() { - Some(query) => format!("{}?{query}", self.url.path()), - None => self.url.path().to_owned(), - }; - wasi_req.set_path_with_query(Some(&path)).unwrap(); + // Set authority + let authority = self.uri.authority().map(|a| a.as_str()); + wasi_req + .set_authority(authority) + .map_err(|()| Error::other(format!("authority rejected by wasi-http {authority:?}")))?; - // Not sure why we also have to set the authority, but sure we can do - // that too! - wasi_req.set_authority(Some(self.url.authority())).unwrap(); + // Set the url path + query string + if let Some(p_and_q) = self.uri.path_and_query() { + wasi_req + .set_path_with_query(Some(&p_and_q.to_string())) + .map_err(|()| { + Error::other(format!("path and query rejected by wasi-http {p_and_q:?}")) + })?; + } // All done; request is ready for send-off - (wasi_req, self.body) + Ok((wasi_req, self.body)) } } diff --git a/src/http/response.rs b/src/http/response.rs index 8c38140..dec56b1 100644 --- a/src/http/response.rs +++ b/src/http/response.rs @@ -1,7 +1,7 @@ use wasi::http::types::{IncomingBody as WasiIncomingBody, IncomingResponse}; use wasi::io::streams::{InputStream, StreamError}; -use super::{Body, Headers, StatusCode}; +use super::{fields::header_map_from_wasi, Body, HeaderMap, StatusCode}; use crate::io::AsyncRead; use crate::runtime::Reactor; @@ -11,7 +11,7 @@ const CHUNK_SIZE: u64 = 2048; /// An HTTP response #[derive(Debug)] pub struct Response { - headers: Headers, + headers: HeaderMap, status: StatusCode, body: B, } @@ -46,7 +46,7 @@ pub struct Response { impl Response { pub(crate) fn try_from_incoming_response(incoming: IncomingResponse) -> super::Result { - let headers: Headers = incoming.headers().into(); + let headers: HeaderMap = header_map_from_wasi(incoming.headers())?; let status = incoming.status().into(); // `body_stream` is a child of `incoming_body` which means we cannot @@ -80,12 +80,12 @@ impl Response { } /// Get the HTTP headers from the impl - pub fn headers(&self) -> &Headers { + pub fn headers(&self) -> &HeaderMap { &self.headers } /// Mutably get the HTTP headers from the impl - pub fn headers_mut(&mut self) -> &mut Headers { + pub fn headers_mut(&mut self) -> &mut HeaderMap { &mut self.headers }