Skip to main content

golem_rust/
http.rs

1// Copyright 2024-2026 Golem Cloud
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Outgoing HTTP over WASI HTTP p3, using standard Rust HTTP request builders.
16//!
17//! Requests have buffered byte bodies; responses can be streamed or collected.
18//! HTTP error statuses are responses, not transport errors. There are no automatic
19//! redirects or retries. Sending requires the WASI p3 runtime used by Golem agents.
20
21use crate::wasip3::http::{client, types};
22use crate::wasip3::http_compat::{BodyWriter, http_from_wasi_response};
23use bytes::Bytes;
24use http_body::Body;
25use http_body_util::{BodyExt, Full};
26use std::future::IntoFuture;
27
28pub use crate::wasip3::http_compat::{IncomingResponseBody, RequestOptionsExtension, Response};
29pub use ::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri, header};
30
31/// Failures sending a request or reading and decoding its response body.
32#[derive(Debug, thiserror::Error)]
33pub enum Error {
34    #[error("WASI HTTP request or response body failed: {0}")]
35    Wasi(#[from] types::ErrorCode),
36    #[error("invalid request headers: {0}")]
37    Headers(#[from] types::HeaderError),
38    #[error("request body upload failed: {0}")]
39    Upload(#[from] crate::wasip3::http_compat::Error),
40    #[error("response body is not UTF-8: {0}")]
41    Utf8(#[from] std::string::FromUtf8Error),
42    #[cfg(feature = "json")]
43    #[error("response body is not valid JSON: {0}")]
44    Json(#[from] serde_json::Error),
45}
46
47/// Send a request, checking both upload and request transmission completion.
48///
49/// Use an absolute HTTP(S) URI and `Vec::new()` for an empty request body.
50/// Optional WASI timeouts can be supplied through a [`RequestOptionsExtension`].
51/// Status codes such as 404 and 500 are returned normally. Inspect the status
52/// and headers before passing the response to a consuming body helper.
53pub async fn send(request: Request<Vec<u8>>) -> Result<Response, Error> {
54    let (parts, body) = request.into_parts();
55    let options = parts
56        .extensions
57        .get::<RequestOptionsExtension>()
58        .cloned()
59        .map(|options| options.0);
60    let headers = types::Fields::try_from(parts.headers)?;
61    let (writer, contents, trailers) = BodyWriter::new();
62    let (request, transmitted) = types::Request::new(headers, Some(contents), trailers, options);
63
64    request
65        .set_method(&parts.method.into())
66        .map_err(|()| types::ErrorCode::HttpRequestMethodInvalid)?;
67    let scheme = parts.uri.scheme().map(Into::into);
68    request
69        .set_scheme(scheme.as_ref())
70        .map_err(|()| types::ErrorCode::HttpProtocolError)?;
71    request
72        .set_authority(parts.uri.authority().map(|authority| authority.as_str()))
73        .map_err(|()| types::ErrorCode::HttpRequestUriInvalid)?;
74    request
75        .set_path_with_query(parts.uri.path_and_query().map(|path| path.as_str()))
76        .map_err(|()| types::ErrorCode::HttpRequestUriInvalid)?;
77
78    let mut body = Full::new(Bytes::from(body));
79    // Uploads must progress while the host sends the request. Unlike the generic
80    // wasip3 conversion, keep and check both completion results.
81    let (response, transmitted, uploaded) = futures::join!(
82        client::send(request),
83        transmitted.into_future(),
84        writer.send_http_body(&mut body),
85    );
86    uploaded?;
87    transmitted?;
88    Ok(http_from_wasi_response(response?)?)
89}
90
91/// Collect the remaining body in memory, including checking its final result.
92/// For large bodies, stream frames from `response.into_body()` instead.
93pub async fn response_bytes(
94    response: Response<impl Body<Data = Bytes, Error = types::ErrorCode>>,
95) -> Result<Bytes, Error> {
96    Ok(response.into_body().collect().await?.to_bytes())
97}
98
99/// Collect the remaining body in memory and decode it as strict UTF-8.
100pub async fn response_text(
101    response: Response<impl Body<Data = Bytes, Error = types::ErrorCode>>,
102) -> Result<String, Error> {
103    Ok(String::from_utf8(response_bytes(response).await?.to_vec())?)
104}
105
106/// Collect the remaining body in memory and decode JSON, regardless of status.
107#[cfg(feature = "json")]
108pub async fn response_json<T: serde::de::DeserializeOwned>(
109    response: Response<impl Body<Data = Bytes, Error = types::ErrorCode>>,
110) -> Result<T, Error> {
111    Ok(serde_json::from_slice(&response_bytes(response).await?)?)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use futures::stream;
118    use http_body::Frame;
119    use http_body_util::StreamBody;
120    use test_r::test;
121
122    fn response(
123        frames: Vec<Result<Frame<Bytes>, types::ErrorCode>>,
124    ) -> Response<impl Body<Data = Bytes, Error = types::ErrorCode>> {
125        Response::builder()
126            .status(StatusCode::BAD_REQUEST)
127            .body(StreamBody::new(stream::iter(frames)))
128            .unwrap()
129    }
130
131    #[test]
132    async fn collects_body_through_trailers_and_reports_late_errors() {
133        let mut trailers = HeaderMap::new();
134        trailers.insert("x-checksum", HeaderValue::from_static("ok"));
135        let frames = vec![
136            Ok(Frame::data(Bytes::from_static(b"abc"))),
137            Ok(Frame::data(Bytes::from_static(b"defgh"))),
138            Ok(Frame::trailers(trailers)),
139        ];
140        assert_eq!(response_bytes(response(frames)).await.unwrap(), "abcdefgh");
141
142        let frames = vec![
143            Ok(Frame::data(Bytes::from_static(b"partial"))),
144            Err(types::ErrorCode::ConnectionTerminated),
145        ];
146        assert!(matches!(
147            response_bytes(response(frames)).await,
148            Err(Error::Wasi(types::ErrorCode::ConnectionTerminated))
149        ));
150    }
151
152    #[test]
153    async fn text_rejects_invalid_utf8() {
154        assert!(matches!(
155            response_text(response(vec![Ok(Frame::data(Bytes::from_static(b"\xff")))])).await,
156            Err(Error::Utf8(_))
157        ));
158    }
159
160    #[cfg(feature = "json")]
161    #[test]
162    async fn json_decodes_error_status_bodies_and_rejects_malformed_json() {
163        let frames = vec![
164            Ok(Frame::data(Bytes::from_static(b"{\"message\":"))),
165            Ok(Frame::data(Bytes::from_static(b"\"bad input\"}"))),
166        ];
167        let value: serde_json::Value = response_json(response(frames)).await.unwrap();
168        assert_eq!(value, serde_json::json!({ "message": "bad input" }));
169        assert!(matches!(
170            response_json::<serde_json::Value>(response(vec![Ok(Frame::data(
171                Bytes::from_static(b"not json")
172            ))]))
173            .await,
174            Err(Error::Json(_))
175        ));
176    }
177}