diff --git a/crates/wasip3/examples/http-proxy-compat.rs b/crates/wasip3/examples/http-proxy-compat.rs index d30d670..1e0064a 100644 --- a/crates/wasip3/examples/http-proxy-compat.rs +++ b/crates/wasip3/examples/http-proxy-compat.rs @@ -9,7 +9,7 @@ impl wasip3::exports::http::handler::Guest for Example { async fn handle(request: types::Request) -> Result { let request = http_from_wasi_request(request)?; let response = serve(request).await?; - http_into_wasi_response(response) + Ok(http_into_wasi_response(response)?) } } diff --git a/crates/wasip3/src/http_compat/body_writer.rs b/crates/wasip3/src/http_compat/body_writer.rs index c892f4a..449e206 100644 --- a/crates/wasip3/src/http_compat/body_writer.rs +++ b/crates/wasip3/src/http_compat/body_writer.rs @@ -1,27 +1,31 @@ use crate::{ - http::types::{ErrorCode, HeaderError, Trailers}, + http::types::{ErrorCode, Trailers}, wit_bindgen::{FutureReader, FutureWriter, StreamReader, StreamWriter}, wit_future, wit_stream, }; use http::HeaderMap; -use http_body::{Body as _, Frame}; +use http_body::{Body, Frame}; use std::future::poll_fn; use std::prelude::v1::*; use std::{fmt::Debug, pin}; -type BoxError = Box; - pub type BodyResult = Result, ErrorCode>; +pub(crate) type BoxError = Box; + #[derive(Debug, thiserror::Error)] -pub enum Error { +pub enum BodyError { /// The [`http_body::Body`] returned an error. #[error("body error: {0}")] HttpBody(#[source] BoxError), - /// Received trailers were rejected by [`Trailers::from_list`]. + /// Error caused by invalid body state. + #[error("{0}")] + InvalidState(&'static str), + + /// Invalid trailers. #[error("invalid trailers: {0}")] - InvalidTrailers(#[source] HeaderError), + InvalidTrailers(#[source] BoxError), /// The result future reader end was closed (dropped). /// @@ -29,6 +33,10 @@ pub enum Error { #[error("result future reader closed")] ResultReaderClosed(BodyResult), + /// Error while sending the body. + #[error("send failed: {0}")] + SendFailed(ErrorCode), + /// The stream reader end was closed (dropped). /// /// The number of bytes written successfully is returned as `written` and @@ -37,6 +45,12 @@ pub enum Error { StreamReaderClosed { written: usize, unwritten: Vec }, } +impl BodyError { + pub(crate) fn invalid_trailers(err: impl Into) -> Self { + Self::InvalidTrailers(err.into()) + } +} + /// BodyWriter coordinates a [`StreamWriter`] and [`FutureWriter`] associated /// with the write end of a `wasi:http` `Request` or `Response` body. pub struct BodyWriter { @@ -65,6 +79,28 @@ impl BodyWriter { ) } + /// Convenience method for short-circuiting empty body processing. + /// + /// If `is_empty` is `false`, behaves like `BodyWriter::new`. + /// + /// If `is_empty` is `true`, returns only a BodyResult future, + /// "pre-resolved" to `Ok(None)`. + pub(crate) fn maybe_empty( + is_empty: bool, + ) -> ( + Option, + Option>, + FutureReader, + ) { + if is_empty { + let (_, result_reader) = wit_future::new(|| Ok(None)); + (None, None, result_reader) + } else { + let (writer, stream_reader, result_reader) = Self::new(); + (Some(writer), Some(stream_reader), result_reader) + } + } + /// Sends the given [`http_body::Body`] to this writer. /// /// This copies all data frames from the body to this writer's stream and @@ -73,7 +109,7 @@ impl BodyWriter { /// trailers) is returned. /// /// If there is an error it is written to the result future. - pub async fn send_http_body(mut self, mut body: &mut T) -> Result + pub async fn send_http_body(mut self, mut body: &mut T) -> Result where T: http_body::Body + Unpin, T::Data: Into>, @@ -90,12 +126,13 @@ impl BodyWriter { total_written += written as u64; } Some(Err(err)) => { + drop(self.stream_writer); let err = err.into(); // TODO: consider if there are better ErrorCode mappings let error_code = ErrorCode::InternalError(Some(err.to_string())); // TODO: log result_writer.write errors? _ = self.result_writer.write(Err(error_code)).await; - return Err(Error::HttpBody(err)); + return Err(BodyError::HttpBody(err)); } None => break, } @@ -104,11 +141,15 @@ impl BodyWriter { let maybe_trailers = if self.trailers.is_empty() { None } else { - Some(self.trailers.try_into().map_err(Error::InvalidTrailers)?) + Some( + self.trailers + .try_into() + .map_err(BodyError::invalid_trailers)?, + ) }; match self.result_writer.write(Ok(maybe_trailers)).await { Ok(()) => Ok(total_written), - Err(err) => Err(Error::ResultReaderClosed(err.value)), + Err(err) => Err(BodyError::ResultReaderClosed(err.value)), } } @@ -118,7 +159,7 @@ impl BodyWriter { /// stream and the size of the written data is returned. /// - If the frame contains trailers they are added to [`Self::trailers`] /// and `Ok(0)` is returned. - pub async fn send_frame(&mut self, frame: Frame) -> Result + pub async fn send_frame(&mut self, frame: Frame) -> Result where T: Into>, { @@ -129,7 +170,7 @@ impl BodyWriter { // write_all returns any unwritten data if the read end is dropped let unwritten = self.stream_writer.write_all(data).await; if !unwritten.is_empty() { - return Err(Error::StreamReaderClosed { + return Err(BodyError::StreamReaderClosed { written: data_len - unwritten.len(), unwritten, }); diff --git a/crates/wasip3/src/http_compat/conversions.rs b/crates/wasip3/src/http_compat/conversions.rs index 9297fdd..a468d4f 100644 --- a/crates/wasip3/src/http_compat/conversions.rs +++ b/crates/wasip3/src/http_compat/conversions.rs @@ -1,11 +1,12 @@ use super::{ - body_writer::BodyWriter, to_internal_error_code, IncomingRequestBody, IncomingResponseBody, - Request as HttpRequest, RequestOptionsExtension, Response as HttpResponse, + body_writer::BodyWriter, IncomingRequestBody, IncomingResponseBody, Request as HttpRequest, + RequestOptionsExtension, Response as HttpResponse, }; use crate::http::types::{ ErrorCode, Fields, HeaderError, Headers, Method, Request as WasiHttpRequest, Response as WasiHttpResponse, Scheme, }; +use crate::http_compat::BoxError; use std::prelude::v1::*; use std::{any::Any, convert::TryFrom}; @@ -21,7 +22,9 @@ use std::{any::Any, convert::TryFrom}; /// - [`http_from_wasi_response`] — converts a WASI response back into a host-side HTTP response. /// - [`BodyWriter`] — for streaming body data into WASI. /// - [`IncomingResponseBody`] — for handling pending or unstarted response states. -pub fn http_into_wasi_response(mut resp: HttpResponse) -> Result +pub fn http_into_wasi_response( + mut resp: HttpResponse, +) -> Result where T: http_body::Body + Any, T::Data: Into>, @@ -34,22 +37,21 @@ where } } - let headers = resp - .headers() - .clone() - .try_into() - .map_err(to_internal_error_code)?; + let headers = resp.headers().clone().try_into()?; - let (body_writer, body_rx, body_result_rx) = BodyWriter::new(); + let is_empty = resp.body().size_hint().exact() == Some(0) || resp.body().is_end_stream(); + let (body_writer, body_rx, body_result_rx) = BodyWriter::maybe_empty(is_empty); - let (response, _future_result) = WasiHttpResponse::new(headers, Some(body_rx), body_result_rx); + let (response, _future_result) = WasiHttpResponse::new(headers, body_rx, body_result_rx); _ = response.set_status_code(resp.status().as_u16()); - wit_bindgen::spawn(async move { - let mut body = std::pin::pin!(resp.into_body()); - _ = body_writer.send_http_body(&mut body).await; - }); + if let Some(body_writer) = body_writer { + wit_bindgen::spawn(async move { + let mut body = std::pin::pin!(resp.into_body()); + _ = body_writer.send_http_body(&mut body).await; + }); + } Ok(response) } @@ -65,8 +67,7 @@ where /// /// - [`http_into_wasi_response`] — the inverse conversion. /// - [`IncomingResponseBody`] — for handling WASI-to-host body streams. -/// - [`ErrorCode`] — for standardized error reporting. -pub fn http_from_wasi_response(resp: WasiHttpResponse) -> Result { +pub fn http_from_wasi_response(resp: WasiHttpResponse) -> Result { let mut builder = http::Response::builder().status(resp.get_status_code()); for (k, v) in resp.get_headers().copy_all() { @@ -74,7 +75,7 @@ pub fn http_from_wasi_response(resp: WasiHttpResponse) -> Result`) into a WASI HTTP request (`WasiHttpRequest`). @@ -89,7 +90,9 @@ pub fn http_from_wasi_response(resp: WasiHttpResponse) -> Result(mut req: HttpRequest) -> Result +pub fn http_into_wasi_request( + mut req: HttpRequest, +) -> Result where T: http_body::Body + Any, T::Data: Into>, @@ -109,11 +112,36 @@ where .cloned() .map(|o| o.0); - let headers = parts.headers.try_into().map_err(to_internal_error_code)?; + let content_length = content_length_from_header_map(&parts.headers)?; + let has_transfer_encoding = parts.headers.contains_key(http::header::TRANSFER_ENCODING); + let body_size = content_length + .or_else(|| body.size_hint().exact()) + .or_else(|| body.is_end_stream().then_some(0)); + + let headers = Headers::try_from(parts.headers)?; + + // RFC 9110 §8.6: A user agent SHOULD send Content-Length in a request when + // the method defines a meaning for enclosed content and it is not sending + // Transfer-Encoding. + if content_length.is_none() + && !has_transfer_encoding + && matches!( + parts.method, + http::Method::POST | http::Method::PUT | http::Method::PATCH + ) + { + if let Some(body_size) = body_size { + let _ignore_immutable = headers.set( + http::header::CONTENT_LENGTH.as_str(), + &[body_size.to_string().into()], + ); + } + } - let (body_writer, contents_rx, trailers_rx) = BodyWriter::new(); + let is_empty = body_size == Some(0); + let (body_writer, contents_rx, trailers_rx) = BodyWriter::maybe_empty(is_empty); - let (req, _result) = WasiHttpRequest::new(headers, Some(contents_rx), trailers_rx, options); + let (req, _result) = WasiHttpRequest::new(headers, contents_rx, trailers_rx, options); req.set_method(&parts.method.into()) .map_err(|()| ErrorCode::HttpRequestMethodInvalid)?; @@ -128,10 +156,12 @@ where req.set_path_with_query(parts.uri.path_and_query().map(|pq| pq.as_str())) .map_err(|()| ErrorCode::HttpRequestUriInvalid)?; - wit_bindgen::spawn(async move { - let mut body = std::pin::pin!(body); - _ = body_writer.send_http_body(&mut body).await; - }); + if let Some(body_writer) = body_writer { + wit_bindgen::spawn(async move { + let mut body = std::pin::pin!(body); + _ = body_writer.send_http_body(&mut body).await; + }); + } Ok(req) } @@ -150,7 +180,7 @@ where /// - [`http_from_wasi_request`] — converts from WASI responses into host responses. /// - [`IncomingRequestBody`] — for streaming WASI request bodies into host code. /// - [`RequestOptionsExtension`] — for carrying optional request metadata. -pub fn http_from_wasi_request(req: WasiHttpRequest) -> Result { +pub fn http_from_wasi_request(req: WasiHttpRequest) -> Result { let uri = { let mut builder = http::Uri::builder(); if let Some(scheme) = req.get_scheme() { @@ -162,9 +192,7 @@ pub fn http_from_wasi_request(req: WasiHttpRequest) -> Result Result Result, ConversionError> { + let mut values = headers.get_all(http::header::CONTENT_LENGTH).into_iter(); + let Some(value) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(ConversionError::invalid_content_length("multiple values")); + } + Ok(Some( + value + .to_str() + .map_err(ConversionError::invalid_content_length)? + .parse() + .map_err(ConversionError::invalid_content_length)?, + )) } impl TryFrom for http::uri::Scheme { @@ -247,15 +294,15 @@ impl From for Method { } impl TryFrom for http::HeaderMap { - type Error = ErrorCode; + type Error = ConversionError; fn try_from(headers: Headers) -> Result { headers .copy_all() .into_iter() .try_fold(http::HeaderMap::new(), |mut map, (k, v)| { - let v = http::HeaderValue::from_bytes(&v).map_err(to_internal_error_code)?; - let k: http::HeaderName = k.parse().map_err(to_internal_error_code)?; + let v = http::HeaderValue::from_bytes(&v)?; + let k: http::HeaderName = k.parse()?; map.append(k, v); Ok(map) }) @@ -285,3 +332,54 @@ impl TryFrom for Fields { Fields::from_list(&entries) } } + +/// An error from converting between [`http`] and wasip3 types. +#[derive(Debug, thiserror::Error)] +pub enum ConversionError { + // /// Error processing body. + // #[error("body error: {0}")] + // BodyError(#[from] super::BodyError), + /// An [`http::Error`]. + #[error(transparent)] + HttpError(#[from] http::Error), + + /// Invalid value for content-length header. + #[error("invalid content-length: {0}")] + InvalidContentLength(BoxError), + + /// A `wasi:http` [`ErrorCode`]. + #[error(transparent)] + WasiErrorCode(#[from] ErrorCode), + + /// Error building `wasi:http` [`Headers`]. + #[error("invalid header(s): {0}")] + WasiHeaderError(#[from] HeaderError), +} + +impl ConversionError { + pub(crate) fn invalid_content_length(err: impl Into) -> Self { + Self::InvalidContentLength(err.into()) + } +} + +impl From for ConversionError { + fn from(err: http::header::InvalidHeaderName) -> Self { + Self::HttpError(err.into()) + } +} + +impl From for ConversionError { + fn from(err: http::header::InvalidHeaderValue) -> Self { + Self::HttpError(err.into()) + } +} + +impl From for ErrorCode { + fn from(err: ConversionError) -> Self { + if let ConversionError::WasiErrorCode(error_code) = err { + error_code + } else { + ErrorCode::InternalError(Some(format!("{err}"))) + } + } +} diff --git a/crates/wasip3/src/http_compat/mod.rs b/crates/wasip3/src/http_compat/mod.rs index 526ab0f..3a600a6 100644 --- a/crates/wasip3/src/http_compat/mod.rs +++ b/crates/wasip3/src/http_compat/mod.rs @@ -145,7 +145,7 @@ impl IncomingBody { /// /// Returns an [`ErrorCode`] if the content length is invalid or cannot /// be determined. - pub fn new(msg: T) -> Result { + pub fn new(msg: T) -> Result { let content_length = get_content_length(msg.get_headers())?; Ok(Self { state: StartedState::Unstarted(msg), @@ -172,7 +172,7 @@ impl IncomingBody { } } - fn ensure_started(&mut self) -> Result<&mut IncomingState, ErrorCode> { + fn ensure_started(&mut self) -> Result<&mut IncomingState, BodyError> { if let StartedState::Unstarted(_) = self.state { let msg = self.take_unstarted().unwrap(); let (result, reader) = wit_future::new(|| Ok(())); @@ -185,7 +185,7 @@ impl IncomingBody { match &mut self.state { StartedState::Started { state, .. } => Ok(state), StartedState::Unstarted(_) => unreachable!(), - StartedState::Empty => Err(to_internal_error_code( + StartedState::Empty => Err(BodyError::InvalidState( "cannot use IncomingBody after call to take_unstarted", )), } @@ -212,7 +212,7 @@ enum ReadResult { impl http_body::Body for IncomingBody { type Data = Bytes; - type Error = ErrorCode; + type Error = BodyError; fn poll_frame( mut self: Pin<&mut Self>, @@ -257,14 +257,15 @@ impl http_body::Body for IncomingBody { *state = IncomingState::Done; match trailers { Ok(Some(fields)) => { - let trailers = fields.try_into()?; + let trailers = + fields.try_into().map_err(BodyError::invalid_trailers)?; break Poll::Ready(Some(Ok(http_body::Frame::trailers( trailers, )))); } Ok(None) => {} Err(e) => { - break Poll::Ready(Some(Err(e))); + break Poll::Ready(Some(Err(BodyError::SendFailed(e)))); } } } @@ -296,20 +297,21 @@ impl http_body::Body for IncomingBody { } } -fn get_content_length(headers: types::Headers) -> Result, ErrorCode> { +fn get_content_length(headers: types::Headers) -> Result, ConversionError> { let values = headers.get(http::header::CONTENT_LENGTH.as_str()); if values.len() > 1 { - return Err(to_internal_error_code("multiple content-length values")); + return Err(ConversionError::invalid_content_length("multiple values")); } let Some(value_bytes) = values.into_iter().next() else { return Ok(None); }; - let value_str = std::str::from_utf8(&value_bytes).map_err(to_internal_error_code)?; - let value_i64: i64 = value_str.parse().map_err(to_internal_error_code)?; - let value = value_i64.try_into().map_err(to_internal_error_code)?; + let value_str = + std::str::from_utf8(&value_bytes).map_err(ConversionError::invalid_content_length)?; + let value_i64: i64 = value_str + .parse() + .map_err(ConversionError::invalid_content_length)?; + let value = value_i64 + .try_into() + .map_err(ConversionError::invalid_content_length)?; Ok(Some(value)) } - -fn to_internal_error_code(e: impl ::std::fmt::Display) -> ErrorCode { - ErrorCode::InternalError(Some(e.to_string())) -}