Skip to content

Commit 7df8719

Browse files
gefjoncoolreader18
andauthored
Add procedure HTTP request API for WASM modules and the Rust module bindings library (#3684)
# Description of Changes Closes #3517 . With this PR, procedures (at least, those defined in Rust modules) can perform HTTP requests! This is performed through a new field on the `ProcedureContext`, `http: HttpClient`, which has a method `send` for sending an `http::Request`, as well as a convenience wrapper `get`. Internally, these methods hit the `procedure_http_request` ABI call / host function, which uses reqwest to perform an HTTP request. The request is run with a user-configurable timeout which defaults and is clamped to 500 ms. Rather than exposing the HTTP stream to modules, we download the entire response body immediately, within the same timeout. I've added an example usage of `get` to `module-test` which performs a request against `localhost:3000` to read its own schema/moduledef. This PR also makes all procedure-related definitions in the Rust module bindings library `#[cfg(feature = "unstable")]`, as per #3644 . The rename of the `/v1/database/:name/procedure/:name` route is not included in this PR, so this does not close #3644 . Left as TODOs are: - Metrics for recording request and response size. - Improving performance by stashing a long-lived `reqwest::Client` someplace. Currently we build a new `Client` for each request. - Improving performance (possibly) by passing the request-future to the global tokio executor rather than running it on the single-threaded database executor. # API and ABI breaking changes Adds new APIs, which are marked as unstable. Adds a new ABI, which is not unstable in any meaningful way (we can't really do that). Marks unreleased APIs as unstable. Does not affect any pre-existing already-released APIs or ABIs. # Expected complexity level and risk 3 or so: networking is scary, and even though we impose a timeout which prevents these connections from being truly long-lived, they're still potentially long-lived on the scale of Tokio futures. It's possible that running them on the database core is problematic in some way, and so what I've left as a performance TODO could actually be a concurrency-correctness issue. # Testing - [x] Manually wrote and executed some procedures which make HTTP requests. - [x] Added two automated tests to the `sdk-test` suite, `procedure::http_ok` and `procedure::http_err`, which make successful and failing requests respectively, then return its result. A client then makes some assertions about the result. --------- Co-authored-by: Noa <coolreader18@gmail.com>
1 parent 6d7b0d8 commit 7df8719

File tree

32 files changed

+1218
-32
lines changed

32 files changed

+1218
-32
lines changed

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ hex = "0.4.3"
196196
home = "0.5"
197197
hostname = "^0.3"
198198
http = "1.0"
199+
http-body-util= "0.1.3"
199200
humantime = "2.1.0"
200201
hyper = "1.0"
201202
hyper-util = { version = "0.1", features = ["tokio"] }

crates/bindings-sys/src/lib.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,47 @@ pub mod raw {
732732
/// This currently does not happen as anonymous read transactions
733733
/// are not exposed to modules.
734734
pub fn procedure_abort_mut_tx() -> u16;
735+
736+
/// Perform an HTTP request as specified by the buffer `request_ptr[..request_len]`,
737+
/// suspending execution until the request is complete,
738+
/// then return its response via a [`BytesSource`] written to `out`.
739+
///
740+
/// `request_ptr[..request_len]` should store a BSATN-serialized `spacetimedb_lib::http::Request` object
741+
/// containing the details of the request to be performed.
742+
///
743+
/// If the request is successful, a [`BytesSource`] is written to `out`
744+
/// containing a BSATN-encoded `spacetimedb_lib::http::Response` object.
745+
/// "Successful" in this context includes any connection which results in any HTTP status code,
746+
/// regardless of the specified meaning of that code.
747+
///
748+
/// # Errors
749+
///
750+
/// Returns an error:
751+
///
752+
/// - `WOULD_BLOCK_TRANSACTION` if there is currently a transaction open.
753+
/// In this case, `out` is not written.
754+
/// - `BSATN_DECODE_ERROR` if `request_ptr[..request_len]` does not contain
755+
/// a valid BSATN-serialized `spacetimedb_lib::http::Request` object.
756+
/// In this case, `out` is not written.
757+
/// - `HTTP_ERROR` if an error occurs while executing the HTTP request.
758+
/// In this case, a [`BytesSource`] is written to `out`
759+
/// containing a BSATN-encoded `spacetimedb_lib::http::Error` object.
760+
///
761+
/// # Traps
762+
///
763+
/// Traps if:
764+
///
765+
/// - `request_ptr` is NULL or `request_ptr[..request_len]` is not in bounds of WASM memory.
766+
/// - `out` is NULL or `out[..size_of::<RowIter>()]` is not in bounds of WASM memory.
767+
/// - `request_ptr[..request_len]` does not contain a valid BSATN-serialized `spacetimedb_lib::http::Request` object.
768+
#[cfg(feature = "unstable")]
769+
pub fn procedure_http_request(
770+
request_ptr: *const u8,
771+
request_len: u32,
772+
body_ptr: *const u8,
773+
body_len: u32,
774+
out: *mut [BytesSource; 2],
775+
) -> u16;
735776
}
736777

737778
/// What strategy does the database index use?
@@ -1397,4 +1438,42 @@ pub mod procedure {
13971438
pub fn procedure_abort_mut_tx() -> Result<()> {
13981439
call_no_ret(|| unsafe { raw::procedure_abort_mut_tx() })
13991440
}
1441+
1442+
#[inline]
1443+
#[cfg(feature = "unstable")]
1444+
/// Perform an HTTP request as specified by `http_request_bsatn`,
1445+
/// suspending execution until the request is complete,
1446+
/// then return its response or error.
1447+
///
1448+
/// `http_request_bsatn` should be a BSATN-serialized `spacetimedb_lib::http::Request`.
1449+
///
1450+
/// If the request completes successfully,
1451+
/// this function returns `Ok(bytes)`, where `bytes` contains a BSATN-serialized `spacetimedb_lib::http::Response`.
1452+
/// All HTTP response codes are treated as successful for these purposes;
1453+
/// this method only returns an error if it is unable to produce any HTTP response whatsoever.
1454+
/// In that case, this function returns `Err(bytes)`, where `bytes` contains a BSATN-serialized `spacetimedb_lib::http::Error`.
1455+
pub fn http_request(
1456+
http_request_bsatn: &[u8],
1457+
body: &[u8],
1458+
) -> Result<(raw::BytesSource, raw::BytesSource), raw::BytesSource> {
1459+
let mut out = [raw::BytesSource::INVALID; 2];
1460+
1461+
let res = unsafe {
1462+
super::raw::procedure_http_request(
1463+
http_request_bsatn.as_ptr(),
1464+
http_request_bsatn.len() as u32,
1465+
body.as_ptr(),
1466+
body.len() as u32,
1467+
&mut out as *mut [raw::BytesSource; 2],
1468+
)
1469+
};
1470+
1471+
match super::Errno::from_code(res) {
1472+
// Success: `out` is a `spacetimedb_lib::http::Response`.
1473+
None => Ok((out[0], out[1])),
1474+
// HTTP_ERROR: `out` is a `spacetimedb_lib::http::Error`.
1475+
Some(errno) if errno == super::Errno::HTTP_ERROR => Err(out[0]),
1476+
Some(errno) => panic!("{errno}"),
1477+
}
1478+
}
14001479
}

crates/bindings/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ spacetimedb-bindings-macro.workspace = true
2626
spacetimedb-primitives.workspace = true
2727

2828
bytemuck.workspace = true
29+
bytes.workspace = true
2930
derive_more.workspace = true
31+
http.workspace = true
3032
log.workspace = true
3133
scoped-tls.workspace = true
3234

crates/bindings/src/http.rs

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
//! Types and utilities for performing HTTP requests in [procedures](crate::procedure).
2+
//!
3+
//! Perform an HTTP request using methods on [`crate::ProcedureContext::http`],
4+
//! which is of type [`HttpClient`].
5+
//! The [`get`](HttpClient::get) helper can be used for simple `GET` requests,
6+
//! while [`send`](HttpClient::send) allows more complex requests with headers, bodies and other methods.
7+
8+
use bytes::Bytes;
9+
pub use http::{Request, Response};
10+
pub use spacetimedb_lib::http::{Error, Timeout};
11+
12+
use crate::{
13+
rt::{read_bytes_source_as, read_bytes_source_into},
14+
IterBuf,
15+
};
16+
use spacetimedb_lib::{bsatn, http as st_http};
17+
18+
/// Allows performing HTTP requests via [`HttpClient::send`] and [`HttpClient::get`].
19+
///
20+
/// Access an `HttpClient` from within [procedures](crate::procedure)
21+
/// via [the `http` field of the `ProcedureContext`](crate::ProcedureContext::http).
22+
#[non_exhaustive]
23+
pub struct HttpClient {}
24+
25+
impl HttpClient {
26+
/// Send the HTTP request `request` and wait for its response.
27+
///
28+
/// For simple `GET` requests with no headers, use [`HttpClient::get`] instead.
29+
///
30+
/// Include a [`Timeout`] in the [`Request::extensions`] via [`http::request::RequestBuilder::extension`]
31+
/// to impose a timeout on the request.
32+
/// All HTTP requests in SpacetimeDB are subject to a maximum timeout of 500 milliseconds.
33+
/// All other extensions in `request` are ignored.
34+
///
35+
/// The returned [`Response`] may have a status code other than 200 OK.
36+
/// Callers should inspect [`Response::status`] to handle errors returned from the remote server.
37+
/// This method returns `Err(err)` only when a connection could not be initiated or was dropped,
38+
/// e.g. due to DNS resolution failure or an unresponsive server.
39+
///
40+
/// # Example
41+
///
42+
/// Send a `POST` request with the header `Content-Type: text/plain`, a string body,
43+
/// and a timeout of 100 milliseconds, then treat the response as a string and log it:
44+
///
45+
/// ```norun
46+
/// # use spacetimedb::{procedure, ProcedureContext};
47+
/// # use spacetimedb::http::{Request, Timeout};
48+
/// # use std::time::Duration;
49+
/// # #[procedure]
50+
/// # fn post_somewhere(ctx: &mut ProcedureContext) {
51+
/// let request = Request::builder()
52+
/// .uri("https://some-remote-host.invalid/upload")
53+
/// .method("POST")
54+
/// .header("Content-Type", "text/plain")
55+
/// // Set a timeout of 100 ms, further restricting the default timeout.
56+
/// .extension(Timeout::from(Duration::from_millis(100)))
57+
/// .body("This is the body of the HTTP request")
58+
/// .expect("Building `Request` object failed");
59+
///
60+
/// match ctx.http.send(request) {
61+
/// Err(err) => {
62+
/// log::error!("HTTP request failed: {err}");
63+
/// },
64+
/// Ok(response) => {
65+
/// let (parts, body) = response.into_parts();
66+
/// log::info!(
67+
/// "Got response with status {}, body {}",
68+
/// parts.status,
69+
/// body.into_string_lossy(),
70+
/// );
71+
/// }
72+
/// }
73+
/// # }
74+
///
75+
/// ```
76+
pub fn send<B: Into<Body>>(&self, request: Request<B>) -> Result<Response<Body>, Error> {
77+
let (request, body) = request.map(Into::into).into_parts();
78+
let request = st_http::Request::from(request);
79+
let request = bsatn::to_vec(&request).expect("Failed to BSATN-serialize `spacetimedb_lib::http::Request`");
80+
81+
match spacetimedb_bindings_sys::procedure::http_request(&request, &body.into_bytes()) {
82+
Ok((response_source, body_source)) => {
83+
let response = read_bytes_source_as::<st_http::Response>(response_source);
84+
let response =
85+
http::response::Parts::try_from(response).expect("Invalid http response returned from host");
86+
let mut buf = IterBuf::take();
87+
read_bytes_source_into(body_source, &mut buf);
88+
let body = Body::from_bytes(buf.clone());
89+
90+
Ok(http::Response::from_parts(response, body))
91+
}
92+
Err(err_source) => {
93+
let error = read_bytes_source_as::<st_http::Error>(err_source);
94+
Err(error)
95+
}
96+
}
97+
}
98+
99+
/// Send a `GET` request to `uri` with no headers and wait for the response.
100+
///
101+
/// # Example
102+
///
103+
/// Send a `GET` request, then treat the response as a string and log it:
104+
///
105+
/// ```no_run
106+
/// # use spacetimedb::{procedure, ProcedureContext};
107+
/// # #[procedure]
108+
/// # fn get_from_somewhere(ctx: &mut ProcedureContext) {
109+
/// match ctx.http.get("https://some-remote-host.invalid/download") {
110+
/// Err(err) => {
111+
/// log::error!("HTTP request failed: {err}");
112+
/// }
113+
/// Ok(response) => {
114+
/// let (parts, body) = response.into_parts();
115+
/// log::info!(
116+
/// "Got response with status {}, body {}",
117+
/// parts.status,
118+
/// body.into_string_lossy(),
119+
/// );
120+
/// }
121+
/// }
122+
/// # }
123+
/// ```
124+
pub fn get(&self, uri: impl TryInto<http::Uri, Error: Into<http::Error>>) -> Result<Response<Body>, Error> {
125+
self.send(
126+
http::Request::builder()
127+
.method("GET")
128+
.uri(uri)
129+
.body(Body::empty())
130+
.map_err(|err| Error::from_display(&err))?,
131+
)
132+
}
133+
}
134+
135+
/// Represents the body of an HTTP request or response.
136+
pub struct Body {
137+
inner: BodyInner,
138+
}
139+
140+
impl Body {
141+
/// Treat the body as a sequence of bytes.
142+
pub fn into_bytes(self) -> Bytes {
143+
match self.inner {
144+
BodyInner::Bytes(bytes) => bytes,
145+
}
146+
}
147+
148+
/// Convert the body into a [`String`], erroring if it is not valid UTF-8.
149+
pub fn into_string(self) -> Result<String, std::string::FromUtf8Error> {
150+
String::from_utf8(self.into_bytes().into())
151+
}
152+
153+
/// Convert the body into a [`String`], replacing invalid UTF-8 with
154+
/// `U+FFFD REPLACEMENT CHARACTER`, which looks like this: �.
155+
///
156+
/// See [`String::from_utf8_lossy`] for more details on the conversion.
157+
pub fn into_string_lossy(self) -> String {
158+
self.into_string()
159+
.unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
160+
}
161+
162+
/// Construct a `Body` consisting of `bytes`.
163+
pub fn from_bytes(bytes: impl Into<Bytes>) -> Body {
164+
Body {
165+
inner: BodyInner::Bytes(bytes.into()),
166+
}
167+
}
168+
169+
/// An empty body, suitable for a `GET` request.
170+
pub fn empty() -> Body {
171+
().into()
172+
}
173+
174+
/// Is `self` exactly zero bytes?
175+
pub fn is_empty(&self) -> bool {
176+
match &self.inner {
177+
BodyInner::Bytes(bytes) => bytes.is_empty(),
178+
}
179+
}
180+
}
181+
182+
impl Default for Body {
183+
fn default() -> Self {
184+
Self::empty()
185+
}
186+
}
187+
188+
macro_rules! impl_body_from_bytes {
189+
($bytes:ident : $t:ty => $conv:expr) => {
190+
impl From<$t> for Body {
191+
fn from($bytes: $t) -> Body {
192+
Body::from_bytes($conv)
193+
}
194+
}
195+
};
196+
($t:ty) => {
197+
impl_body_from_bytes!(bytes : $t => bytes);
198+
};
199+
}
200+
201+
impl_body_from_bytes!(String);
202+
impl_body_from_bytes!(Vec<u8>);
203+
impl_body_from_bytes!(Box<[u8]>);
204+
impl_body_from_bytes!(&'static [u8]);
205+
impl_body_from_bytes!(&'static str);
206+
impl_body_from_bytes!(_unit: () => Bytes::new());
207+
208+
enum BodyInner {
209+
Bytes(Bytes),
210+
}

0 commit comments

Comments
 (0)