diff --git a/Cargo.lock b/Cargo.lock index b2e8e99950b..8b0daef28c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5334,6 +5334,8 @@ version = "1.8.0" dependencies = [ "anyhow", "env_logger 0.10.2", + "serde_json", + "spacetimedb-lib 1.8.0", "spacetimedb-sdk", "test-counter", ] @@ -7098,8 +7100,10 @@ name = "spacetimedb" version = "1.8.0" dependencies = [ "bytemuck", + "bytes", "derive_more 0.99.20", "getrandom 0.2.16", + "http 1.3.1", "insta", "log", "rand 0.8.5", @@ -7448,6 +7452,8 @@ dependencies = [ "hashbrown 0.15.5", "hex", "hostname", + "http 1.3.1", + "http-body-util", "hyper 1.7.0", "imara-diff", "indexmap 2.12.0", @@ -7710,8 +7716,10 @@ dependencies = [ "enum-as-inner", "enum-map", "hex", + "http 1.3.1", "insta", "itertools 0.12.1", + "log", "proptest", "proptest-derive", "ron", diff --git a/Cargo.toml b/Cargo.toml index ee371ec2be0..e8797fc1eea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -196,6 +196,7 @@ hex = "0.4.3" home = "0.5" hostname = "^0.3" http = "1.0" +http-body-util= "0.1.3" humantime = "2.1.0" hyper = "1.0" hyper-util = { version = "0.1", features = ["tokio"] } diff --git a/crates/bindings-sys/src/lib.rs b/crates/bindings-sys/src/lib.rs index e1d49d0ff55..751b6ba8cb0 100644 --- a/crates/bindings-sys/src/lib.rs +++ b/crates/bindings-sys/src/lib.rs @@ -732,6 +732,47 @@ pub mod raw { /// This currently does not happen as anonymous read transactions /// are not exposed to modules. pub fn procedure_abort_mut_tx() -> u16; + + /// Perform an HTTP request as specified by the buffer `request_ptr[..request_len]`, + /// suspending execution until the request is complete, + /// then return its response via a [`BytesSource`] written to `out`. + /// + /// `request_ptr[..request_len]` should store a BSATN-serialized `spacetimedb_lib::http::Request` object + /// containing the details of the request to be performed. + /// + /// If the request is successful, a [`BytesSource`] is written to `out` + /// containing a BSATN-encoded `spacetimedb_lib::http::Response` object. + /// "Successful" in this context includes any connection which results in any HTTP status code, + /// regardless of the specified meaning of that code. + /// + /// # Errors + /// + /// Returns an error: + /// + /// - `WOULD_BLOCK_TRANSACTION` if there is currently a transaction open. + /// In this case, `out` is not written. + /// - `BSATN_DECODE_ERROR` if `request_ptr[..request_len]` does not contain + /// a valid BSATN-serialized `spacetimedb_lib::http::Request` object. + /// In this case, `out` is not written. + /// - `HTTP_ERROR` if an error occurs while executing the HTTP request. + /// In this case, a [`BytesSource`] is written to `out` + /// containing a BSATN-encoded `spacetimedb_lib::http::Error` object. + /// + /// # Traps + /// + /// Traps if: + /// + /// - `request_ptr` is NULL or `request_ptr[..request_len]` is not in bounds of WASM memory. + /// - `out` is NULL or `out[..size_of::()]` is not in bounds of WASM memory. + /// - `request_ptr[..request_len]` does not contain a valid BSATN-serialized `spacetimedb_lib::http::Request` object. + #[cfg(feature = "unstable")] + pub fn procedure_http_request( + request_ptr: *const u8, + request_len: u32, + body_ptr: *const u8, + body_len: u32, + out: *mut [BytesSource; 2], + ) -> u16; } /// What strategy does the database index use? @@ -1397,4 +1438,42 @@ pub mod procedure { pub fn procedure_abort_mut_tx() -> Result<()> { call_no_ret(|| unsafe { raw::procedure_abort_mut_tx() }) } + + #[inline] + #[cfg(feature = "unstable")] + /// Perform an HTTP request as specified by `http_request_bsatn`, + /// suspending execution until the request is complete, + /// then return its response or error. + /// + /// `http_request_bsatn` should be a BSATN-serialized `spacetimedb_lib::http::Request`. + /// + /// If the request completes successfully, + /// this function returns `Ok(bytes)`, where `bytes` contains a BSATN-serialized `spacetimedb_lib::http::Response`. + /// All HTTP response codes are treated as successful for these purposes; + /// this method only returns an error if it is unable to produce any HTTP response whatsoever. + /// In that case, this function returns `Err(bytes)`, where `bytes` contains a BSATN-serialized `spacetimedb_lib::http::Error`. + pub fn http_request( + http_request_bsatn: &[u8], + body: &[u8], + ) -> Result<(raw::BytesSource, raw::BytesSource), raw::BytesSource> { + let mut out = [raw::BytesSource::INVALID; 2]; + + let res = unsafe { + super::raw::procedure_http_request( + http_request_bsatn.as_ptr(), + http_request_bsatn.len() as u32, + body.as_ptr(), + body.len() as u32, + &mut out as *mut [raw::BytesSource; 2], + ) + }; + + match super::Errno::from_code(res) { + // Success: `out` is a `spacetimedb_lib::http::Response`. + None => Ok((out[0], out[1])), + // HTTP_ERROR: `out` is a `spacetimedb_lib::http::Error`. + Some(errno) if errno == super::Errno::HTTP_ERROR => Err(out[0]), + Some(errno) => panic!("{errno}"), + } + } } diff --git a/crates/bindings/Cargo.toml b/crates/bindings/Cargo.toml index d852597e42b..cb2473fc1fd 100644 --- a/crates/bindings/Cargo.toml +++ b/crates/bindings/Cargo.toml @@ -26,7 +26,9 @@ spacetimedb-bindings-macro.workspace = true spacetimedb-primitives.workspace = true bytemuck.workspace = true +bytes.workspace = true derive_more.workspace = true +http.workspace = true log.workspace = true scoped-tls.workspace = true diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs new file mode 100644 index 00000000000..4f4ef4c1982 --- /dev/null +++ b/crates/bindings/src/http.rs @@ -0,0 +1,210 @@ +//! Types and utilities for performing HTTP requests in [procedures](crate::procedure). +//! +//! Perform an HTTP request using methods on [`crate::ProcedureContext::http`], +//! which is of type [`HttpClient`]. +//! The [`get`](HttpClient::get) helper can be used for simple `GET` requests, +//! while [`send`](HttpClient::send) allows more complex requests with headers, bodies and other methods. + +use bytes::Bytes; +pub use http::{Request, Response}; +pub use spacetimedb_lib::http::{Error, Timeout}; + +use crate::{ + rt::{read_bytes_source_as, read_bytes_source_into}, + IterBuf, +}; +use spacetimedb_lib::{bsatn, http as st_http}; + +/// Allows performing HTTP requests via [`HttpClient::send`] and [`HttpClient::get`]. +/// +/// Access an `HttpClient` from within [procedures](crate::procedure) +/// via [the `http` field of the `ProcedureContext`](crate::ProcedureContext::http). +#[non_exhaustive] +pub struct HttpClient {} + +impl HttpClient { + /// Send the HTTP request `request` and wait for its response. + /// + /// For simple `GET` requests with no headers, use [`HttpClient::get`] instead. + /// + /// Include a [`Timeout`] in the [`Request::extensions`] via [`http::request::RequestBuilder::extension`] + /// to impose a timeout on the request. + /// All HTTP requests in SpacetimeDB are subject to a maximum timeout of 500 milliseconds. + /// All other extensions in `request` are ignored. + /// + /// The returned [`Response`] may have a status code other than 200 OK. + /// Callers should inspect [`Response::status`] to handle errors returned from the remote server. + /// This method returns `Err(err)` only when a connection could not be initiated or was dropped, + /// e.g. due to DNS resolution failure or an unresponsive server. + /// + /// # Example + /// + /// Send a `POST` request with the header `Content-Type: text/plain`, a string body, + /// and a timeout of 100 milliseconds, then treat the response as a string and log it: + /// + /// ```norun + /// # use spacetimedb::{procedure, ProcedureContext}; + /// # use spacetimedb::http::{Request, Timeout}; + /// # use std::time::Duration; + /// # #[procedure] + /// # fn post_somewhere(ctx: &mut ProcedureContext) { + /// let request = Request::builder() + /// .uri("https://some-remote-host.invalid/upload") + /// .method("POST") + /// .header("Content-Type", "text/plain") + /// // Set a timeout of 100 ms, further restricting the default timeout. + /// .extension(Timeout::from(Duration::from_millis(100))) + /// .body("This is the body of the HTTP request") + /// .expect("Building `Request` object failed"); + /// + /// match ctx.http.send(request) { + /// Err(err) => { + /// log::error!("HTTP request failed: {err}"); + /// }, + /// Ok(response) => { + /// let (parts, body) = response.into_parts(); + /// log::info!( + /// "Got response with status {}, body {}", + /// parts.status, + /// body.into_string_lossy(), + /// ); + /// } + /// } + /// # } + /// + /// ``` + pub fn send>(&self, request: Request) -> Result, Error> { + let (request, body) = request.map(Into::into).into_parts(); + let request = st_http::Request::from(request); + let request = bsatn::to_vec(&request).expect("Failed to BSATN-serialize `spacetimedb_lib::http::Request`"); + + match spacetimedb_bindings_sys::procedure::http_request(&request, &body.into_bytes()) { + Ok((response_source, body_source)) => { + let response = read_bytes_source_as::(response_source); + let response = + http::response::Parts::try_from(response).expect("Invalid http response returned from host"); + let mut buf = IterBuf::take(); + read_bytes_source_into(body_source, &mut buf); + let body = Body::from_bytes(buf.clone()); + + Ok(http::Response::from_parts(response, body)) + } + Err(err_source) => { + let error = read_bytes_source_as::(err_source); + Err(error) + } + } + } + + /// Send a `GET` request to `uri` with no headers and wait for the response. + /// + /// # Example + /// + /// Send a `GET` request, then treat the response as a string and log it: + /// + /// ```no_run + /// # use spacetimedb::{procedure, ProcedureContext}; + /// # #[procedure] + /// # fn get_from_somewhere(ctx: &mut ProcedureContext) { + /// match ctx.http.get("https://some-remote-host.invalid/download") { + /// Err(err) => { + /// log::error!("HTTP request failed: {err}"); + /// } + /// Ok(response) => { + /// let (parts, body) = response.into_parts(); + /// log::info!( + /// "Got response with status {}, body {}", + /// parts.status, + /// body.into_string_lossy(), + /// ); + /// } + /// } + /// # } + /// ``` + pub fn get(&self, uri: impl TryInto>) -> Result, Error> { + self.send( + http::Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty()) + .map_err(|err| Error::from_display(&err))?, + ) + } +} + +/// Represents the body of an HTTP request or response. +pub struct Body { + inner: BodyInner, +} + +impl Body { + /// Treat the body as a sequence of bytes. + pub fn into_bytes(self) -> Bytes { + match self.inner { + BodyInner::Bytes(bytes) => bytes, + } + } + + /// Convert the body into a [`String`], erroring if it is not valid UTF-8. + pub fn into_string(self) -> Result { + String::from_utf8(self.into_bytes().into()) + } + + /// Convert the body into a [`String`], replacing invalid UTF-8 with + /// `U+FFFD REPLACEMENT CHARACTER`, which looks like this: �. + /// + /// See [`String::from_utf8_lossy`] for more details on the conversion. + pub fn into_string_lossy(self) -> String { + self.into_string() + .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()) + } + + /// Construct a `Body` consisting of `bytes`. + pub fn from_bytes(bytes: impl Into) -> Body { + Body { + inner: BodyInner::Bytes(bytes.into()), + } + } + + /// An empty body, suitable for a `GET` request. + pub fn empty() -> Body { + ().into() + } + + /// Is `self` exactly zero bytes? + pub fn is_empty(&self) -> bool { + match &self.inner { + BodyInner::Bytes(bytes) => bytes.is_empty(), + } + } +} + +impl Default for Body { + fn default() -> Self { + Self::empty() + } +} + +macro_rules! impl_body_from_bytes { + ($bytes:ident : $t:ty => $conv:expr) => { + impl From<$t> for Body { + fn from($bytes: $t) -> Body { + Body::from_bytes($conv) + } + } + }; + ($t:ty) => { + impl_body_from_bytes!(bytes : $t => bytes); + }; +} + +impl_body_from_bytes!(String); +impl_body_from_bytes!(Vec); +impl_body_from_bytes!(Box<[u8]>); +impl_body_from_bytes!(&'static [u8]); +impl_body_from_bytes!(&'static str); +impl_body_from_bytes!(_unit: () => Bytes::new()); + +enum BodyInner { + Bytes(Bytes), +} diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 74923e095a6..6479bdba771 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -8,6 +8,8 @@ use std::rc::Rc; #[cfg(feature = "unstable")] mod client_visibility_filter; +#[cfg(feature = "unstable")] +pub mod http; pub mod log_stopwatch; mod logger; #[cfg(feature = "rand08")] @@ -739,6 +741,7 @@ pub use spacetimedb_bindings_macro::reducer; /// [clients]: https://spacetimedb.com/docs/#client // TODO(procedure-async): update docs and examples with `async`-ness. #[doc(inline)] +#[cfg(feature = "unstable")] pub use spacetimedb_bindings_macro::procedure; /// Marks a function as a spacetimedb view. @@ -1042,6 +1045,8 @@ impl Deref for TxContext { /// /// Includes information about the client calling the procedure and the time of invocation, /// and exposes methods for running transactions and performing side-effecting operations. +#[non_exhaustive] +#[cfg(feature = "unstable")] pub struct ProcedureContext { /// The `Identity` of the client that invoked the procedure. pub sender: Identity, @@ -1053,11 +1058,15 @@ pub struct ProcedureContext { /// /// Will be `None` for certain scheduled procedures. pub connection_id: Option, + + /// Methods for performing HTTP requests. + pub http: crate::http::HttpClient, // TODO: Add rng? // Complex and requires design because we may want procedure RNG to behave differently from reducer RNG, // as it could actually be seeded by OS randomness rather than a deterministic source. } +#[cfg(feature = "unstable")] impl ProcedureContext { /// Read the current module's [`Identity`]. pub fn identity(&self) -> Identity { diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index 9a37e605709..08da263ea68 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -2,12 +2,11 @@ use crate::table::IndexAlgo; use crate::{ - sys, AnonymousViewContext, IterBuf, LocalReadOnly, ProcedureContext, ProcedureResult, ReducerContext, - ReducerResult, SpacetimeType, Table, ViewContext, + sys, AnonymousViewContext, IterBuf, LocalReadOnly, ReducerContext, ReducerResult, SpacetimeType, Table, ViewContext, }; pub use spacetimedb_lib::db::raw_def::v9::Lifecycle as LifecycleReducer; use spacetimedb_lib::db::raw_def::v9::{RawIndexAlgorithm, RawModuleDefV9Builder, TableType}; -use spacetimedb_lib::de::{self, Deserialize, Error as _, SeqProductAccess}; +use spacetimedb_lib::de::{self, Deserialize, DeserializeOwned, Error as _, SeqProductAccess}; use spacetimedb_lib::sats::typespace::TypespaceBuilder; use spacetimedb_lib::sats::{impl_deserialize, impl_serialize, ProductTypeElement}; use spacetimedb_lib::ser::{Serialize, SerializeSeqProduct}; @@ -17,7 +16,10 @@ use std::convert::Infallible; use std::fmt; use std::marker::PhantomData; use std::sync::{Mutex, OnceLock}; -use sys::raw::{BytesSink, BytesSource}; +pub use sys::raw::{BytesSink, BytesSource}; + +#[cfg(feature = "unstable")] +use crate::{ProcedureContext, ProcedureResult}; pub trait IntoVec { fn into_vec(self) -> Vec; @@ -50,6 +52,7 @@ pub fn invoke_reducer<'a, A: Args<'a>>( reducer.invoke(&ctx, args) } +#[cfg(feature = "unstable")] pub fn invoke_procedure<'a, A: Args<'a>, Ret: IntoProcedureResult>( procedure: impl Procedure<'a, A, Ret>, mut ctx: ProcedureContext, @@ -58,8 +61,6 @@ pub fn invoke_procedure<'a, A: Args<'a>, Ret: IntoProcedureResult>( // Deserialize the arguments from a bsatn encoding. let SerDeArgs(args) = bsatn::from_slice(args).expect("unable to decode args"); - // TODO(procedure-async): get a future out of `procedure.invoke` and call `FutureExt::now_or_never` on it? - // Or maybe do that within the `Procedure::invoke` method? let res = procedure.invoke(&mut ctx, args); res.to_result() @@ -140,7 +141,11 @@ pub trait FnInfo { /// The type of function to invoke. type Invoke; - /// One of [`FnKindReducer`], [`FnKindProcedure`] or [`FnKindView`]. + #[cfg_attr( + feature = "unstable", + doc = "One of [`FnKindReducer`], [`FnKindProcedure`] or [`FnKindView`]." + )] + #[cfg_attr(not(feature = "unstable"), doc = "Either [`FnKindReducer`] or [`FnKindView`].")] /// /// Used as a type argument to [`ExportFunctionForScheduledTable`] and [`scheduled_typecheck`]. /// See for details on this technique. @@ -165,6 +170,7 @@ pub trait FnInfo { } } +#[cfg(feature = "unstable")] pub trait Procedure<'de, A: Args<'de>, Ret: IntoProcedureResult> { fn invoke(&self, ctx: &mut ProcedureContext, args: A) -> Ret; } @@ -211,6 +217,7 @@ impl IntoReducerResult for Result<(), E> { } } +#[cfg(feature = "unstable")] #[diagnostic::on_unimplemented( message = "The procedure return type `{Self}` does not implement `SpacetimeType`", note = "if you own the type, try adding `#[derive(SpacetimeType)]` to its definition" @@ -221,6 +228,7 @@ pub trait IntoProcedureResult: SpacetimeType + Serialize { bsatn::to_vec(&self).expect("Failed to serialize procedure result") } } +#[cfg(feature = "unstable")] impl IntoProcedureResult for T {} #[diagnostic::on_unimplemented( @@ -246,6 +254,7 @@ pub trait ReducerArg { } impl ReducerArg for T {} +#[cfg(feature = "unstable")] #[diagnostic::on_unimplemented( message = "the first argument of a procedure must be `&mut ProcedureContext`", label = "first argument must be `&mut ProcedureContext`" @@ -255,9 +264,11 @@ pub trait ProcedureContextArg { #[doc(hidden)] const _ITEM: () = (); } +#[cfg(feature = "unstable")] impl ProcedureContextArg for &mut ProcedureContext {} /// A trait of types that can be an argument of a procedure. +#[cfg(feature = "unstable")] #[diagnostic::on_unimplemented( message = "the procedure argument `{Self}` does not implement `SpacetimeType`", note = "if you own the type, try adding `#[derive(SpacetimeType)]` to its definition" @@ -267,6 +278,7 @@ pub trait ProcedureArg { #[doc(hidden)] const _ITEM: () = (); } +#[cfg(feature = "unstable")] impl ProcedureArg for T {} #[diagnostic::on_unimplemented( @@ -389,6 +401,7 @@ pub struct FnKindReducer { _never: Infallible, } +#[cfg(feature = "unstable")] /// Tacit marker argument to [`ExportFunctionForScheduledTable`] for procedures. /// /// Holds the procedure's return type in order to avoid an error due to an unconstrained type argument. @@ -409,15 +422,22 @@ pub struct FnKindView { /// /// The `FnKind` parameter here is a coherence-defeating marker, which Will Crichton calls a "tacit parameter." /// See for details on this technique. -/// It will be one of [`FnKindReducer`] or [`FnKindProcedure`] in modules that compile successfully. +#[cfg_attr( + feature = "unstable", + doc = "It will be one of [`FnKindReducer`] or [`FnKindProcedure`] in modules that compile successfully." +)] +#[cfg_attr( + not(feature = "unstable"), + doc = "It will be [`FnKindReducer`] in modules that compile successfully." +)] +/// /// It may be [`FnKindView`], but that will always fail to typecheck, as views cannot be used as scheduled functions. #[diagnostic::on_unimplemented( message = "invalid signature for scheduled table reducer or procedure", note = "views cannot be scheduled", note = "the scheduled function must take `{TableRow}` as its sole argument", note = "e.g: `fn scheduled_reducer(ctx: &ReducerContext, arg: {TableRow})`", - // TODO(procedure-async): amend this to `async fn` once procedures are `async`-ified - note = "or `fn scheduled_procedure(ctx: &mut ProcedureContext, arg: {TableRow})`", + note = "or `fn scheduled_procedure(ctx: &mut ProcedureContext, arg: {TableRow})`" )] pub trait ExportFunctionForScheduledTable<'de, TableRow, FnKind> {} impl<'de, TableRow: SpacetimeType + Serialize + Deserialize<'de>, F: Reducer<'de, (TableRow,)>> @@ -425,6 +445,7 @@ impl<'de, TableRow: SpacetimeType + Serialize + Deserialize<'de>, F: Reducer<'de { } +#[cfg(feature = "unstable")] impl< 'de, TableRow: SpacetimeType + Serialize + Deserialize<'de>, @@ -560,6 +581,7 @@ macro_rules! impl_reducer_procedure_view { } } + #[cfg(feature = "unstable")] impl<'de, Func, Ret, $($T: SpacetimeType + Deserialize<'de> + Serialize),*> Procedure<'de, ($($T,)*), Ret> for Func where Func: Fn(&mut ProcedureContext, $($T),*) -> Ret, @@ -711,6 +733,7 @@ pub fn register_reducer<'a, A: Args<'a>, I: FnInfo>(_: impl }) } +#[cfg(feature = "unstable")] pub fn register_procedure<'a, A, Ret, I>(_: impl Procedure<'a, A, Ret>) where A: Args<'a>, @@ -774,6 +797,7 @@ pub struct ModuleBuilder { /// The reducers of the module. reducers: Vec, /// The procedures of the module. + #[cfg(feature = "unstable")] procedures: Vec, /// The client specific views of the module. views: Vec, @@ -789,7 +813,9 @@ static DESCRIBERS: Mutex>> = Mutex::new(Vec::new()); pub type ReducerFn = fn(ReducerContext, &[u8]) -> ReducerResult; static REDUCERS: OnceLock> = OnceLock::new(); +#[cfg(feature = "unstable")] pub type ProcedureFn = fn(ProcedureContext, &[u8]) -> ProcedureResult; +#[cfg(feature = "unstable")] static PROCEDURES: OnceLock> = OnceLock::new(); /// A view function takes in `(ViewContext, Args)` and returns a Vec of bytes. @@ -830,6 +856,7 @@ extern "C" fn __describe_module__(description: BytesSink) { // Write the sets of reducers, procedures and views. REDUCERS.set(module.reducers).ok().unwrap(); + #[cfg(feature = "unstable")] PROCEDURES.set(module.procedures).ok().unwrap(); VIEWS.set(module.views).ok().unwrap(); ANONYMOUS_VIEWS.set(module.views_anon).ok().unwrap(); @@ -966,6 +993,7 @@ fn convert_err_to_errno(res: Result<(), Box>, out: BytesSink) -> i16 { /// the BSATN-serialized bytes of a value of the procedure's return type. /// /// Procedures always return the error 0. All other return values are reserved. +#[cfg(feature = "unstable")] #[no_mangle] extern "C" fn __call_procedure__( id: usize, @@ -992,6 +1020,7 @@ extern "C" fn __call_procedure__( connection_id: conn_id, sender, timestamp, + http: crate::http::HttpClient {}, }; // Grab the list of procedures, which is populated by the preinit functions. @@ -1096,7 +1125,7 @@ pub fn get_jwt(connection_id: ConnectionId) -> Option { } /// Read `source` from the host fully into `buf`. -fn read_bytes_source_into(source: BytesSource, buf: &mut Vec) { +pub(crate) fn read_bytes_source_into(source: BytesSource, buf: &mut Vec) { const INVALID: i16 = NO_SUCH_BYTES as i16; // For reducer arguments, the `buf` will almost certainly already be large enough, @@ -1192,3 +1221,15 @@ pub fn volatile_nonatomic_schedule_immediate<'de, A: Args<'de>, R: Reducer<'de, // Schedule the reducer. sys::volatile_nonatomic_schedule_immediate(R2::NAME, &arg_bytes) } + +/// Read `source` completely into a temporary buffer, then BSATN-deserialize it as a `T`. +/// +/// Panics if the bytes from `source` fail to deserialize as `T`. +/// The type name of `T` will be included in the panic message. +#[cfg_attr(not(feature = "unstable"), allow(unused))] +pub(crate) fn read_bytes_source_as(source: BytesSource) -> T { + let mut buf = IterBuf::take(); + read_bytes_source_into(source, &mut buf); + bsatn::from_slice::(&buf) + .unwrap_or_else(|err| panic!("Failed to BSATN-deserialize `{}`: {err:#?}", std::any::type_name::())) +} diff --git a/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap b/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap index a0b8fc583e8..8d3943d8bda 100644 --- a/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap +++ b/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap @@ -2,9 +2,10 @@ source: crates/bindings/tests/deps.rs expression: "cargo tree -p spacetimedb -e no-dev --color never --target wasm32-unknown-unknown -f {lib}" --- -total crates: 66 +total crates: 68 spacetimedb ├── bytemuck +├── bytes ├── derive_more │ ├── convert_case │ ├── proc_macro2 @@ -20,6 +21,10 @@ spacetimedb │ └── semver ├── getrandom │ └── cfg_if +├── http +│ ├── bytes +│ ├── fnv +│ └── itoa ├── log ├── rand │ ├── rand_chacha @@ -78,7 +83,9 @@ spacetimedb │ ├── derive_more (*) │ ├── enum_as_inner (*) │ ├── hex +│ ├── http (*) │ ├── itertools (*) +│ ├── log │ ├── spacetimedb_bindings_macro (*) │ ├── spacetimedb_primitives (*) │ ├── spacetimedb_sats diff --git a/crates/bindings/tests/ui/tables.stderr b/crates/bindings/tests/ui/tables.stderr index 5595b21b9d5..caaacce0195 100644 --- a/crates/bindings/tests/ui/tables.stderr +++ b/crates/bindings/tests/ui/tables.stderr @@ -131,9 +131,9 @@ error[E0277]: `&'a Alpha` cannot appear as an argument to an index filtering ope &Lifecycle &TableAccess &TableType + &Version &bool ðnum::int::I256 - ðnum::uint::U256 and $N others note: required by a bound in `UniqueColumn::::ColType, Col>::find` --> src/table.rs @@ -158,9 +158,9 @@ error[E0277]: the trait bound `Alpha: IndexScanRangeBounds<(Alpha,), SingleBound &Lifecycle &TableAccess &TableType + &Version &bool ðnum::int::I256 - ðnum::uint::U256 and $N others = note: required for `Alpha` to implement `IndexScanRangeBounds<(Alpha,), SingleBound>` note: required by a bound in `RangedIndex::::filter` diff --git a/crates/bindings/tests/ui/views.stderr b/crates/bindings/tests/ui/views.stderr index 9023a417716..9f46b8ff2e8 100644 --- a/crates/bindings/tests/ui/views.stderr +++ b/crates/bindings/tests/ui/views.stderr @@ -137,8 +137,9 @@ error[E0599]: no method named `try_insert` found for reference `&test__ViewHandl | ^^^^^^^^^^ | = help: items from traits can only be used if the trait is implemented and in scope - = note: the following trait defines an item `try_insert`, perhaps you need to implement it: + = note: the following traits define an item `try_insert`, perhaps you need to implement one of them: candidate #1: `Table` + candidate #2: `http::header::map::into_header_name::Sealed` help: there is a method `try_into` with a similar name, but with different arguments --> $RUST/core/src/convert/mod.rs | diff --git a/crates/codegen/tests/snapshots/codegen__codegen_csharp.snap b/crates/codegen/tests/snapshots/codegen__codegen_csharp.snap index feb9f549b3a..f5c9147b9a0 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_csharp.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_csharp.snap @@ -2,6 +2,7 @@ source: crates/codegen/tests/codegen.rs expression: outfiles --- +"Procedures/GetMySchemaViaHttp.g.cs" = '' "Procedures/ReturnValue.g.cs" = '' "Procedures/SleepOneSecond.g.cs" = '' "Procedures/WithTx.g.cs" = '' diff --git a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap index f62b7589cdc..95c9ce2fac1 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap @@ -815,6 +815,60 @@ impl __sdk::InModule for Foobar { type Module = super::RemoteModule; } +''' +"get_my_schema_via_http_procedure.rs" = ''' +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct GetMySchemaViaHttpArgs { + } + + +impl __sdk::InModule for GetMySchemaViaHttpArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_my_schema_via_http`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_my_schema_via_http { + fn get_my_schema_via_http(&self, ) { + self.get_my_schema_via_http_then( |_, _| {}); + } + + fn get_my_schema_via_http_then( + &self, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl get_my_schema_via_http for super::RemoteProcedures { + fn get_my_schema_via_http_then( + &self, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, String>( + "get_my_schema_via_http", + GetMySchemaViaHttpArgs { }, + __callback, + ); + } +} + ''' "has_special_stuff_table.rs" = ''' // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE @@ -1422,6 +1476,7 @@ pub mod test_d_table; pub mod test_e_table; pub mod test_f_table; pub mod my_player_table; +pub mod get_my_schema_via_http_procedure; pub mod return_value_procedure; pub mod sleep_one_second_procedure; pub mod with_tx_procedure; @@ -1469,6 +1524,7 @@ pub use repeating_test_reducer::{repeating_test, set_flags_for_repeating_test, R pub use say_hello_reducer::{say_hello, set_flags_for_say_hello, SayHelloCallbackId}; pub use test_reducer::{test, set_flags_for_test, TestCallbackId}; pub use test_btree_index_args_reducer::{test_btree_index_args, set_flags_for_test_btree_index_args, TestBtreeIndexArgsCallbackId}; +pub use get_my_schema_via_http_procedure::get_my_schema_via_http; pub use return_value_procedure::return_value; pub use sleep_one_second_procedure::sleep_one_second; pub use with_tx_procedure::with_tx; diff --git a/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap b/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap index dc5d4f6422f..6c0c925a0e7 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_typescript.snap @@ -164,6 +164,7 @@ export default Foobar; ''' +"get_my_schema_via_http_procedure.ts" = '' "has_special_stuff_table.ts" = ''' // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 63d62fcfc74..1c5b1cbef0b 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -63,6 +63,8 @@ futures-util.workspace = true hashbrown = { workspace = true, features = ["rayon", "serde"] } hex.workspace = true hostname.workspace = true +http.workspace = true +http-body-util.workspace = true hyper.workspace = true imara-diff.workspace = true indexmap.workspace = true @@ -81,6 +83,7 @@ prometheus.workspace = true rayon.workspace = true rayon-core.workspace = true regex.workspace = true +reqwest.workspace = true rustc-demangle.workspace = true rustc-hash.workspace = true scopeguard.workspace = true @@ -155,7 +158,6 @@ env_logger.workspace = true pretty_assertions.workspace = true jsonwebtoken.workspace = true axum.workspace = true -reqwest.workspace = true fs_extra.workspace = true [lints] diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index adba06a8991..6a41beaebf1 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -17,6 +17,7 @@ use thiserror::Error; use crate::client::ClientActorId; use crate::host::module_host::ViewCallError; use crate::host::scheduler::ScheduleError; +use crate::host::AbiCall; use spacetimedb_lib::buffer::DecodeError; use spacetimedb_primitives::*; use spacetimedb_sats::hash::Hash; @@ -266,8 +267,8 @@ pub enum NodesError { BadColumn, #[error("can't perform operation; not inside transaction")] NotInTransaction, - #[error("can't perform operation; a transaction already exists")] - WouldBlockTransaction, + #[error("ABI call not allowed while holding open a transaction: {0}")] + WouldBlockTransaction(AbiCall), #[error("table with name {0:?} already exists")] AlreadyExists(String), #[error("table with name `{0}` start with 'st_' and that is reserved for internal system tables.")] @@ -280,6 +281,8 @@ pub enum NodesError { BadIndexType(u8), #[error("Failed to scheduled timer: {0}")] ScheduleError(#[source] ScheduleError), + #[error("HTTP request failed: {0}")] + HttpError(#[from] spacetimedb_lib::http::Error), } impl From for NodesError { diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index be2bc6a5e20..aeff5127bb6 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -13,7 +13,7 @@ use spacetimedb_datastore::execution_context::Workload; use spacetimedb_datastore::locking_tx_datastore::state_view::StateView; use spacetimedb_datastore::locking_tx_datastore::{FuncCallType, MutTxId}; use spacetimedb_datastore::traits::IsolationLevel; -use spacetimedb_lib::{ConnectionId, Identity, Timestamp}; +use spacetimedb_lib::{http as st_http, ConnectionId, Identity, Timestamp}; use spacetimedb_primitives::{ColId, ColList, IndexId, TableId}; use spacetimedb_sats::{ bsatn::{self, ToBsatn}, @@ -25,7 +25,7 @@ use spacetimedb_table::table::RowRef; use std::fmt::Display; use std::ops::DerefMut; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use std::vec::IntoIter; #[derive(Clone)] @@ -207,6 +207,11 @@ impl InstanceEnv { self.tx.get() } + /// True if `self` is holding an open transaction, or false if it is not. + pub fn in_tx(&self) -> bool { + self.get_tx().is_ok() + } + pub(crate) fn take_tx(&self) -> Result { self.tx.take() } @@ -588,7 +593,9 @@ impl InstanceEnv { pub async fn start_mutable_tx(&mut self) -> Result<(), NodesError> { if self.get_tx().is_ok() { - return Err(NodesError::WouldBlockTransaction); + return Err(NodesError::WouldBlockTransaction( + super::AbiCall::ProcedureStartMutTransaction, + )); } let stdb = self.replica_ctx.relational_db.clone(); @@ -598,8 +605,93 @@ impl InstanceEnv { Ok(()) } + + pub async fn http_request( + &mut self, + request: st_http::Request, + body: bytes::Bytes, + ) -> Result<(st_http::Response, bytes::Bytes), NodesError> { + if self.in_tx() { + // If we're holding a transaction open, refuse to perform this blocking operation. + return Err(NodesError::WouldBlockTransaction(super::AbiCall::ProcedureHttpRequest)); + } + + // TODO(procedure-metrics): record size in bytes of request. + + // Then convert the request into an `http::Request`, a semi-standard "lingua franca" type in the Rust ecosystem, + // and map its body into a type `reqwest` will like. + fn convert_request(request: st_http::Request, body: bytes::Bytes) -> Result { + let mut request: http::request::Parts = + request.try_into().map_err(|err| st_http::Error::from_display(&err))?; + + // Pull our timeout extension, if any, out of the `http::Request` extensions. + // reqwest has its own timeout extension, which is where we'll provide this. + let timeout = request.extensions.remove::(); + + let request = http::Request::from_parts(request, body.to_vec()); + + let mut reqwest: reqwest::Request = request.try_into().map_err(|err| st_http::Error::from_display(&err))?; + + // If the user requested a timeout using our extension, slot it in to reqwest's timeout. + // Clamp to the range `0..HTTP_DEFAULT_TIMEOUT`. + let timeout = timeout + .map(|timeout| timeout.timeout.to_duration().unwrap_or(Duration::ZERO)) + .unwrap_or(HTTP_DEFAULT_TIMEOUT) + .min(HTTP_DEFAULT_TIMEOUT); + + // reqwest's timeout covers from the start of the request to the end of reading the body, + // so there's no need to do our own timeout operation. + *reqwest.timeout_mut() = Some(timeout); + + Ok(reqwest) + } + + // If for whatever reason reqwest doesn't like our `http::Request`, + // surface that error to the guest so customers can debug and provide a more appropriate request. + let reqwest = convert_request(request, body)?; + + // TODO(procedure-metrics): record size in bytes of response, time spent awaiting response. + + // Actually execute the HTTP request! + // We'll wrap this future in a `tokio::time::timeout` before `await`ing it. + let get_response_and_download_body = async { + // TODO(perf): Stash a long-lived `Client` in the env somewhere, rather than building a new one for each call. + let response = reqwest::Client::new() + .execute(reqwest) + .await + .map_err(|err| st_http::Error::from_display(&err))?; + + // Download the response body, which in all likelihood will be a stream, + // as reqwest seems to prefer that. + // Note that this will be wrapped in the same `tokio::time::timeout` as the above `execute` call. + let (parts, body) = http::Response::from(response).into_parts(); + let body = http_body_util::BodyExt::collect(body) + .await + .map_err(|err| st_http::Error::from_display(&err))?; + + // Map the collected body into our `spacetimedb_lib::http::Body` type, + // then wrap it back in an `http::Response`. + Ok::<_, st_http::Error>((parts, body.to_bytes())) + }; + + // If the request failed, surface that error to the guest so customer logic can handle it. + let (response, body) = get_response_and_download_body.await?; + + // Transform the `http::Response` into our `spacetimedb_lib::http::Response` type, + // which has a stable BSATN encoding to pass across the WASM boundary. + let response = st_http::Response::from(response); + + Ok((response, body)) + } } +/// Default / maximum timeout for HTTP requests performed by [`InstanceEnv::http_request`]. +/// +/// If the user requests a timeout longer than this, we will clamp to this value. +/// +/// Value chosen arbitrarily by pgoldman 2025-11-18, based on little more than a vague guess. +const HTTP_DEFAULT_TIMEOUT: Duration = Duration::from_millis(500); + impl TxSlot { /// Sets the slot to `tx`, ensuring that there was no tx before. pub fn set_raw(&mut self, tx: MutTxId) { diff --git a/crates/core/src/host/mod.rs b/crates/core/src/host/mod.rs index a0dc7e77ca1..87d119a8309 100644 --- a/crates/core/src/host/mod.rs +++ b/crates/core/src/host/mod.rs @@ -181,4 +181,5 @@ pub enum AbiCall { ProcedureStartMutTransaction, ProcedureCommitMutTransaction, ProcedureAbortMutTransaction, + ProcedureHttpRequest, } diff --git a/crates/core/src/host/wasm_common.rs b/crates/core/src/host/wasm_common.rs index 72ded8a5999..e5155a475f7 100644 --- a/crates/core/src/host/wasm_common.rs +++ b/crates/core/src/host/wasm_common.rs @@ -347,14 +347,16 @@ pub(super) type TimingSpanSet = ResourceSlab; pub fn err_to_errno(err: &NodesError) -> Option { match err { NodesError::NotInTransaction => Some(errno::NOT_IN_TRANSACTION), - NodesError::WouldBlockTransaction => Some(errno::WOULD_BLOCK_TRANSACTION), + NodesError::WouldBlockTransaction(_) => Some(errno::WOULD_BLOCK_TRANSACTION), NodesError::DecodeRow(_) => Some(errno::BSATN_DECODE_ERROR), + NodesError::DecodeValue(_) => Some(errno::BSATN_DECODE_ERROR), NodesError::TableNotFound => Some(errno::NO_SUCH_TABLE), NodesError::IndexNotFound => Some(errno::NO_SUCH_INDEX), NodesError::IndexNotUnique => Some(errno::INDEX_NOT_UNIQUE), NodesError::IndexRowNotFound => Some(errno::NO_SUCH_ROW), NodesError::ScheduleError(ScheduleError::DelayTooLong(_)) => Some(errno::SCHEDULE_AT_DELAY_TOO_LONG), NodesError::AlreadyExists(_) => Some(errno::UNIQUE_ALREADY_EXISTS), + NodesError::HttpError(_) => Some(errno::HTTP_ERROR), NodesError::Internal(internal) => match **internal { DBError::Datastore(DatastoreError::Index(IndexError::UniqueConstraintViolation( UniqueConstraintViolation { @@ -426,6 +428,7 @@ macro_rules! abi_funcs { "spacetime_10.3"::procedure_start_mut_tx, "spacetime_10.3"::procedure_commit_mut_tx, "spacetime_10.3"::procedure_abort_mut_tx, + "spacetime_10.3"::procedure_http_request, } }; } diff --git a/crates/core/src/host/wasmtime/wasm_instance_env.rs b/crates/core/src/host/wasmtime/wasm_instance_env.rs index 1e37bc48642..f41d040a7ff 100644 --- a/crates/core/src/host/wasmtime/wasm_instance_env.rs +++ b/crates/core/src/host/wasmtime/wasm_instance_env.rs @@ -13,15 +13,14 @@ use crate::subscription::module_subscription_actor::{commit_and_broadcast_event, use crate::subscription::module_subscription_manager::{from_tx_offset, TransactionOffset}; use crate::util::asyncify; use anyhow::Context as _; -use core::time::Duration; use spacetimedb_client_api_messages::energy::EnergyQuanta; use spacetimedb_data_structures::map::IntMap; use spacetimedb_datastore::locking_tx_datastore::FuncCallType; -use spacetimedb_lib::{ConnectionId, Timestamp}; +use spacetimedb_lib::{bsatn, ConnectionId, Timestamp}; use spacetimedb_primitives::{errno, ColId}; use std::future::Future; use std::num::NonZeroU32; -use std::time::Instant; +use std::time::{Duration, Instant}; use wasmtime::{AsContext, Caller, StoreContextMut}; /// A stream of bytes which the WASM module can read from @@ -1657,6 +1656,110 @@ impl WasmInstanceEnv { res => unreachable!("should've had a tx to close; {res:?}"), } } + + /// Perform an HTTP request as specified by the buffer `request_ptr[..request_len]`, + /// suspending execution until the request is complete, + /// then return its response details via a [`BytesSource`] written to `out[0]` + /// and its response body via a [`BytesSource`] written to `out[1]`. + /// + /// `request_ptr[..request_len]` should store a BSATN-serialized [`spacetimedb_lib::http::Request`] object + /// containing the details of the request to be performed. + /// + /// `body_ptr[..body_len]` should store the body of the request to be performed; + /// + /// If the request is successful, a [`BytesSource`] is written to `out` + /// containing a BSATN-encoded [`spacetimedb_lib::http::Response`] object. + /// "Successful" in this context includes any connection which results in any HTTP status code, + /// regardless of the specified meaning of that code. + /// This includes HTTP error codes such as 404 Not Found and 500 Internal Server Error. + /// + /// # Errors + /// + /// Returns an error: + /// + /// - `WOULD_BLOCK_TRANSACTION` if there is currently a transaction open. + /// In this case, `out` is not written. + /// - `BSATN_DECODE_ERROR` if `request_ptr[..request_len]` does not contain + /// a valid BSATN-serialized [`spacetimedb_lib::http::Request`] object. + /// In this case, `out` is not written. + /// - `HTTP_ERROR` if an error occurs while executing the HTTP request. + /// In this case, a [`BytesSource`] is written to `out` + /// containing a BSATN-encoded [`spacetimedb_lib::http::Error`] object. + /// + /// # Traps + /// + /// Traps if: + /// + /// - `request_ptr` is NULL or `request_ptr[..request_len]` is not in bounds of WASM memory. + /// - `body_ptr` is NULL or `body_ptr[..body_len]` is not in bounds of WASM memory. + /// - `out` is NULL or `out[..size_of::()]` is not in bounds of WASM memory. + pub fn procedure_http_request<'caller>( + caller: Caller<'caller, Self>, + (request_ptr, request_len, body_ptr, body_len, out): (WasmPtr, u32, WasmPtr, u32, WasmPtr), + ) -> Fut<'caller, RtResult> { + use spacetimedb_lib::http as st_http; + + Self::async_with_span(caller, AbiCall::ProcedureHttpRequest, move |mut caller| async move { + let (mem, env) = Self::mem_env(&mut caller); + + // Yes clippy, I'm calling a closure at its definition site *on purpose*, + // as a hacky-but-stable `try` block. + #[allow(clippy::redundant_closure_call)] + let res = (async move || { + // TODO(procedure-metrics): record size in bytes of request. + + // Read the request from memory as a `spacetimedb_lib::http::Request`, + // our bespoke type with a stable layout and BSATN encoding. + let request_buf = mem.deref_slice(request_ptr, request_len)?; + let request = bsatn::from_slice::(request_buf).map_err(|err| { + // This goes to `errno::BSATN_DECODE_ERROR` in `Self::convert_wasm_result`. + NodesError::DecodeValue(err) + })?; + + let body_buf = mem.deref_slice(body_ptr, body_len)?; + let body = bytes::Bytes::copy_from_slice(body_buf); + + let result = env + .instance_env + .http_request(request, body) + // TODO(perf): Evaluate whether it's better to run this future on the "global" I/O Tokio executor, + // rather than the thread-local database executors. + .await; + + match result { + Ok((response, body)) => { + let result = bsatn::to_vec(&response) + .with_context(|| "Failed to BSATN serialize `st_http::Response` object".to_string())?; + + let bytes_source = WasmInstanceEnv::create_bytes_source(env, result.into())?; + bytes_source.0.write_to(mem, out)?; + + let bytes_source = WasmInstanceEnv::create_bytes_source(env, body)?; + bytes_source + .0 + .write_to(mem, out.saturating_add(size_of::() as u32))?; + + Ok(0u32) + } + Err(NodesError::HttpError(err)) => { + let result = bsatn::to_vec(&err).with_context(|| { + format!("Failed to BSATN serialize `spacetimedb_lib::http::Error` object {err:#?}") + })?; + let bytes_source = WasmInstanceEnv::create_bytes_source(env, result.into())?; + bytes_source.0.write_to(mem, out)?; + Ok(errno::HTTP_ERROR.get() as u32) + } + Err(e) => Err(WasmError::Db(e)), + } + })() + .await; + + ( + caller, + res.or_else(|err| Self::convert_wasm_result(AbiCall::ProcedureHttpRequest, err)), + ) + }) + } } type Fut<'caller, T> = Box>; diff --git a/crates/core/src/util/jobs.rs b/crates/core/src/util/jobs.rs index 059e2486278..3ca0e787b36 100644 --- a/crates/core/src/util/jobs.rs +++ b/crates/core/src/util/jobs.rs @@ -74,6 +74,9 @@ impl CoreInfo { // Enable the timer system so that `procedure_sleep_until` can work. // TODO(procedure-sleep): Remove this. .enable_time() + // Enable the IO system so that `procedure_http_request` can work. + // TODO(perf): Disable this and move HTTP requests to the global executor? + .enable_io() .on_thread_start({ use std::sync::atomic::{AtomicBool, Ordering}; let already_spawned_worker = AtomicBool::new(false); diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index fbd1d335413..d0f13095c5c 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -43,7 +43,9 @@ chrono.workspace = true derive_more.workspace = true enum-as-inner.workspace = true hex.workspace = true +http.workspace = true itertools.workspace = true +log.workspace = true serde = { workspace = true, optional = true } thiserror.workspace = true blake3.workspace = true diff --git a/crates/lib/src/http.rs b/crates/lib/src/http.rs new file mode 100644 index 00000000000..ce0c46e981f --- /dev/null +++ b/crates/lib/src/http.rs @@ -0,0 +1,367 @@ +//! `SpacetimeType`-ified HTTP request, response and error types, +//! for use in the procedure HTTP API. +//! +//! The types here are all mirrors of various types within the [`http`] crate. +//! That crate's types don't have stable representations or `pub`lic interiors, +//! so we're forced to define our own representation for the SATS serialization. +//! These types are that representation. +//! +//! Users aren't intended to interact with these types, +//! except [`Timeout`] and [`Error`], which are re-exported from the `bindings` crate. +//! Our user-facing APIs should use the [`http`] crate's types directly, and convert to and from these types internally. +//! +//! These types are used in BSATN encoding for interchange between the SpacetimeDB host +//! and guest WASM modules in the `procedure_http_request` ABI call. +//! For that reason, the layout of these types must not change. +//! Because we want, to the extent possible, +//! to support both (old host, new guest) and (new host, old guest) pairings, +//! we can't meaningfully make these types extensible, even with tricks like version enum wrappers. +//! Instead, if/when we want to add new functionality which requires sending additional information, +//! we'll define a new versioned ABI call which uses new types for interchange. + +use spacetimedb_sats::{time_duration::TimeDuration, SpacetimeType}; + +/// Represents an HTTP request which can be made from a procedure running in a SpacetimeDB database. +/// +/// Construct instances of this type by converting from [`http::Request`]. +/// Note that all extensions to [`http::Request`] save for [`Timeout`] are ignored. +#[derive(Clone, SpacetimeType)] +#[sats(crate = crate)] +pub struct Request { + method: Method, + headers: Headers, + timeout: Option, + /// A valid URI, sourced from an already-validated [`http::Uri`]. + uri: String, + version: Version, +} + +impl From for Request { + fn from(parts: http::request::Parts) -> Request { + let http::request::Parts { + method, + uri, + version, + headers, + mut extensions, + .. + } = parts; + + let timeout = extensions.remove::(); + if !extensions.is_empty() { + log::warn!("Converting HTTP `Request` with unrecognized extensions"); + } + Request { + method: method.into(), + headers: headers.into(), + timeout, + uri: uri.to_string(), + version: version.into(), + } + } +} + +impl TryFrom for http::request::Parts { + type Error = http::Error; + fn try_from(req: Request) -> http::Result { + let Request { + method, + headers, + timeout, + uri, + version, + } = req; + let (mut request, ()) = http::Request::new(()).into_parts(); + request.method = method.into(); + request.uri = uri.try_into()?; + request.version = version.into(); + request.headers = headers.try_into()?; + + if let Some(timeout) = timeout { + request.extensions.insert(timeout); + } + + Ok(request) + } +} + +/// An HTTP extension to specify a timeout for requests made by a procedure running in a SpacetimeDB database. +/// +/// Pass an instance of this type to [`http::request::Builder::extension`] to set a timeout on a request. +/// +/// This timeout applies to the entire request, +/// from when the headers are first sent to when the response body is fully downloaded. +/// This is sometimes called a total timeout, the sum of the connect timeout and the read timeout. +#[derive(Clone, SpacetimeType, Copy, PartialEq, Eq)] +#[sats(crate = crate)] +pub struct Timeout { + pub timeout: TimeDuration, +} + +impl From for Timeout { + fn from(timeout: TimeDuration) -> Timeout { + Timeout { timeout } + } +} + +impl From for TimeDuration { + fn from(Timeout { timeout }: Timeout) -> TimeDuration { + timeout + } +} + +/// Represents an HTTP method. +#[derive(Clone, SpacetimeType, PartialEq, Eq)] +#[sats(crate = crate)] +pub enum Method { + Get, + Head, + Post, + Put, + Delete, + Connect, + Options, + Trace, + Patch, + Extension(String), +} + +impl Method { + pub const GET: Method = Method::Get; + pub const HEAD: Method = Method::Head; + pub const POST: Method = Method::Post; + pub const PUT: Method = Method::Put; + pub const DELETE: Method = Method::Delete; + pub const CONNECT: Method = Method::Connect; + pub const OPTIONS: Method = Method::Options; + pub const TRACE: Method = Method::Trace; + pub const PATCH: Method = Method::Patch; +} + +impl From for Method { + fn from(method: http::Method) -> Method { + match method { + http::Method::GET => Method::Get, + http::Method::HEAD => Method::Head, + http::Method::POST => Method::Post, + http::Method::PUT => Method::Put, + http::Method::DELETE => Method::Delete, + http::Method::CONNECT => Method::Connect, + http::Method::OPTIONS => Method::Options, + http::Method::TRACE => Method::Trace, + http::Method::PATCH => Method::Patch, + _ => Method::Extension(method.to_string()), + } + } +} + +impl From for http::Method { + fn from(method: Method) -> http::Method { + match method { + Method::Get => http::Method::GET, + Method::Head => http::Method::HEAD, + Method::Post => http::Method::POST, + Method::Put => http::Method::PUT, + Method::Delete => http::Method::DELETE, + Method::Connect => http::Method::CONNECT, + Method::Options => http::Method::OPTIONS, + Method::Trace => http::Method::TRACE, + Method::Patch => http::Method::PATCH, + Method::Extension(method) => http::Method::from_bytes(method.as_bytes()).expect("Invalid HTTP method"), + } + } +} + +/// An HTTP version. +#[derive(Clone, SpacetimeType, PartialEq, Eq)] +#[sats(crate = crate)] +pub enum Version { + Http09, + Http10, + Http11, + Http2, + Http3, +} + +impl From for Version { + fn from(version: http::Version) -> Version { + match version { + http::Version::HTTP_09 => Version::Http09, + http::Version::HTTP_10 => Version::Http10, + http::Version::HTTP_11 => Version::Http11, + http::Version::HTTP_2 => Version::Http2, + http::Version::HTTP_3 => Version::Http3, + _ => unreachable!("Unknown HTTP version: {version:?}"), + } + } +} + +impl From for http::Version { + fn from(version: Version) -> http::Version { + match version { + Version::Http09 => http::Version::HTTP_09, + Version::Http10 => http::Version::HTTP_10, + Version::Http11 => http::Version::HTTP_11, + Version::Http2 => http::Version::HTTP_2, + Version::Http3 => http::Version::HTTP_3, + } + } +} + +/// A set of HTTP headers. +/// +/// Construct this by converting from a [`http::HeaderMap`]. +#[derive(Clone, SpacetimeType)] +#[sats(crate = crate)] +pub struct Headers { + // SATS doesn't (and won't) have a multimap type, so just use an array of pairs for the ser/de format. + entries: Box<[HttpHeaderPair]>, +} + +impl From> for Headers { + fn from(value: http::HeaderMap) -> Headers { + Headers { + entries: value + .into_iter() + .map(|(name, value)| HttpHeaderPair { + name: name.map(|name| name.to_string()).unwrap_or_default(), + value: value.into(), + }) + .collect(), + } + } +} + +impl TryFrom for http::HeaderMap { + type Error = http::Error; + fn try_from(headers: Headers) -> http::Result { + let Headers { entries } = headers; + let mut new_headers = http::HeaderMap::with_capacity(entries.len() / 2); + for HttpHeaderPair { name, value } in entries { + new_headers.insert(http::HeaderName::try_from(name)?, value.try_into()?); + } + Ok(new_headers) + } +} + +#[derive(Clone, SpacetimeType)] +#[sats(crate = crate)] +struct HttpHeaderPair { + /// A valid HTTP header name, sourced from an already-validated [`http::HeaderName`]. + name: String, + value: HeaderValue, +} + +/// A valid HTTP header value, sourced from an already-validated [`http::HeaderValue`]. +#[derive(Clone, SpacetimeType)] +#[sats(crate = crate)] +struct HeaderValue { + bytes: Box<[u8]>, + is_sensitive: bool, +} + +impl From for HeaderValue { + fn from(value: http::HeaderValue) -> HeaderValue { + HeaderValue { + is_sensitive: value.is_sensitive(), + bytes: value.as_bytes().into(), + } + } +} + +impl TryFrom for http::HeaderValue { + type Error = http::Error; + fn try_from(value: HeaderValue) -> http::Result { + let mut new_value = http::HeaderValue::from_bytes(&value.bytes)?; + new_value.set_sensitive(value.is_sensitive); + Ok(new_value) + } +} + +#[derive(Clone, SpacetimeType)] +#[sats(crate = crate)] +pub struct Response { + inner: HttpResponse, +} + +impl TryFrom for http::response::Parts { + type Error = http::Error; + fn try_from(response: Response) -> http::Result { + let Response { + inner: HttpResponse { headers, version, code }, + } = response; + + let (mut response, ()) = http::Response::new(()).into_parts(); + response.version = version.into(); + response.status = http::StatusCode::from_u16(code)?; + response.headers = headers.try_into()?; + Ok(response) + } +} + +impl From for Response { + fn from(response: http::response::Parts) -> Response { + let http::response::Parts { + extensions, + headers, + status, + version, + .. + } = response; + if !extensions.is_empty() { + log::warn!("Converting HTTP `Response` with unrecognized extensions"); + } + Response { + inner: HttpResponse { + headers: headers.into(), + version: version.into(), + code: status.as_u16(), + }, + } + } +} + +#[derive(Clone, SpacetimeType)] +#[sats(crate = crate)] +struct HttpResponse { + headers: Headers, + version: Version, + /// A valid HTTP response status code, sourced from an already-validated [`http::StatusCode`]. + code: u16, +} + +/// Errors that may arise from HTTP calls. +#[derive(Clone, SpacetimeType, Debug)] +#[sats(crate = crate)] +pub struct Error { + /// A string message describing the error. + /// + /// It would be nice if we could store a more interesting object here, + /// ideally a type-erased `dyn Trait` cause, + /// rather than just a string, similar to how `anyhow` does. + /// This is not possible because we need to serialize `Error` for transport to WASM, + /// meaning it must have a concrete static type. + /// `reqwest::Error`, which is the source for these, + /// is type-erased enough that the best we can do (at least, the best we can do easily) + /// is to eagerly string-ify the error. + message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let Error { message } = self; + f.write_str(message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn from_string(message: String) -> Self { + Error { message } + } + + pub fn from_display(t: &impl std::fmt::Display) -> Self { + Self::from_string(format!("{t}")) + } +} diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index f533f076986..b86db057472 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -11,6 +11,7 @@ pub mod db; mod direct_index_key; pub mod error; mod filterable_value; +pub mod http; pub mod identity; pub mod metrics; pub mod operator; diff --git a/crates/primitives/src/errno.rs b/crates/primitives/src/errno.rs index 484a556f7da..139d6fb2ae2 100644 --- a/crates/primitives/src/errno.rs +++ b/crates/primitives/src/errno.rs @@ -33,6 +33,7 @@ macro_rules! errnos { 20, "ABI call can only be made while within a read-only transaction" ), + HTTP_ERROR(21, "The HTTP request failed"), ); }; } diff --git a/modules/module-test/src/lib.rs b/modules/module-test/src/lib.rs index 2ca26f8c6a5..c745cd18457 100644 --- a/modules/module-test/src/lib.rs +++ b/modules/module-test/src/lib.rs @@ -471,3 +471,17 @@ fn return_value(_ctx: &mut ProcedureContext, foo: u64) -> Baz { fn with_tx(ctx: &mut ProcedureContext) { ctx.with_tx(|tx| say_hello(tx)); } + +/// Hit SpacetimeDB's schema HTTP route and return its result as a string. +/// +/// This is a silly thing to do, but an effective test of the procedure HTTP API. +#[spacetimedb::procedure] +fn get_my_schema_via_http(ctx: &mut ProcedureContext) -> String { + let module_identity = ctx.identity(); + match ctx.http.get(format!( + "http://localhost:3000/v1/database/{module_identity}/schema?version=9" + )) { + Ok(result) => result.into_body().into_string_lossy(), + Err(e) => format!("{e}"), + } +} diff --git a/modules/sdk-test-procedure/src/lib.rs b/modules/sdk-test-procedure/src/lib.rs index 7af5e56eeee..88a45a6a6ea 100644 --- a/modules/sdk-test-procedure/src/lib.rs +++ b/modules/sdk-test-procedure/src/lib.rs @@ -37,13 +37,27 @@ fn will_panic(_ctx: &mut ProcedureContext) { panic!("This procedure is expected to panic") } -// TODO(procedure-http): Add a procedure here which does an HTTP request against a SpacetimeDB route (as `http://localhost:3000/v1/`) -// and returns some value derived from the response. -// Then write a test which invokes it in the Rust client SDK test suite. +#[procedure] +fn read_my_schema(ctx: &mut ProcedureContext) -> String { + let module_identity = ctx.identity(); + match ctx.http.get(format!( + "http://localhost:3000/v1/database/{module_identity}/schema?version=9" + )) { + Ok(result) => result.into_body().into_string_lossy(), + Err(e) => panic!("{e}"), + } +} -// TODO(procedure-http): Add a procedure here which does an HTTP request against an invalid SpacetimeDB route -// and returns some value derived from the error. -// Then write a test which invokes it in the Rust client SDK test suite. +#[procedure] +fn invalid_request(ctx: &mut ProcedureContext) -> String { + match ctx.http.get("http://foo.invalid/") { + Ok(result) => panic!( + "Got result from requesting `http://foo.invalid`... huh?\n{}", + result.into_body().into_string_lossy() + ), + Err(e) => e.to_string(), + } +} #[table(public, name = my_table)] struct MyTable { diff --git a/sdks/rust/tests/procedure-client/Cargo.toml b/sdks/rust/tests/procedure-client/Cargo.toml index 615bba2f39f..665d4b0582d 100644 --- a/sdks/rust/tests/procedure-client/Cargo.toml +++ b/sdks/rust/tests/procedure-client/Cargo.toml @@ -8,9 +8,11 @@ license-file = "LICENSE" [dependencies] spacetimedb-sdk = { path = "../.." } +spacetimedb-lib.workspace = true test-counter = { path = "../test-counter" } anyhow.workspace = true env_logger.workspace = true +serde_json.workspace = true [lints] workspace = true diff --git a/sdks/rust/tests/procedure-client/src/main.rs b/sdks/rust/tests/procedure-client/src/main.rs index 62098812b44..2a7c66c90eb 100644 --- a/sdks/rust/tests/procedure-client/src/main.rs +++ b/sdks/rust/tests/procedure-client/src/main.rs @@ -2,6 +2,7 @@ mod module_bindings; use anyhow::Context; use module_bindings::*; +use spacetimedb_lib::db::raw_def::v9::{RawMiscModuleExportV9, RawModuleDefV9}; use spacetimedb_sdk::{DbConnectionBuilder, DbContext, Table}; use test_counter::TestCounter; @@ -38,6 +39,8 @@ fn main() { match &*test { "procedure-return-values" => exec_procedure_return_values(), "procedure-observe-panic" => exec_procedure_panic(), + "procedure-http-ok" => exec_procedure_http_ok(), + "procedure-http-err" => exec_procedure_http_err(), "insert-with-tx-commit" => exec_insert_with_tx_commit(), "insert-with-tx-rollback" => exec_insert_with_tx_rollback(), _ => panic!("Unknown test: {test}"), @@ -244,3 +247,68 @@ fn exec_insert_with_tx_rollback() { test_counter.wait_for_all(); } + +/// Test that a procedure can perform an HTTP request and return a string derived from the response. +/// +/// Invoke the procedure `read_my_schema`, +/// which does an HTTP request to the `/database/schema` route and returns a JSON-ified [`RawModuleDefV9`], +/// then (in the client) deserialize the response and assert that it contains a description of that procedure. +fn exec_procedure_http_ok() { + let test_counter = TestCounter::new(); + connect_then(&test_counter, { + let test_counter = test_counter.clone(); + move |ctx| { + let result = test_counter.add_test("invoke_http"); + ctx.procedures.read_my_schema_then(move |_ctx, res| { + result( + // It's a try block! + #[allow(clippy::redundant_closure_call)] + (|| { + anyhow::ensure!(res.is_ok()); + let module_def: RawModuleDefV9 = spacetimedb_lib::de::serde::deserialize_from( + &mut serde_json::Deserializer::from_str(&res.unwrap()), + )?; + anyhow::ensure!(module_def.misc_exports.iter().any(|misc_export| { + if let RawMiscModuleExportV9::Procedure(procedure_def) = misc_export { + &*procedure_def.name == "read_my_schema" + } else { + false + } + })); + Ok(()) + })(), + ) + }) + } + }); + test_counter.wait_for_all(); +} + +/// Test that a procedure can perform an HTTP request, handle its failure and return a string derived from the error. +/// +/// Invoke the procedure `invalid_request`, +/// which does an HTTP request to a reserved invalid URL and returns a string-ified error, +/// then (in the client) assert that the error message looks sane. +fn exec_procedure_http_err() { + let test_counter = TestCounter::new(); + connect_then(&test_counter, { + let test_counter = test_counter.clone(); + move |ctx| { + let result = test_counter.add_test("invoke_http"); + ctx.procedures.invalid_request_then(move |_ctx, res| { + result( + // It's a try block! + #[allow(clippy::redundant_closure_call)] + (|| { + anyhow::ensure!(res.is_ok()); + let error = res.unwrap(); + anyhow::ensure!(error.contains("error")); + anyhow::ensure!(error.contains("http://foo.invalid/")); + Ok(()) + })(), + ) + }) + } + }); + test_counter.wait_for_all(); +} diff --git a/sdks/rust/tests/procedure-client/src/module_bindings/invalid_request_procedure.rs b/sdks/rust/tests/procedure-client/src/module_bindings/invalid_request_procedure.rs new file mode 100644 index 00000000000..66d7dcb8f08 --- /dev/null +++ b/sdks/rust/tests/procedure-client/src/module_bindings/invalid_request_procedure.rs @@ -0,0 +1,40 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct InvalidRequestArgs {} + +impl __sdk::InModule for InvalidRequestArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `invalid_request`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait invalid_request { + fn invalid_request(&self) { + self.invalid_request_then(|_, _| {}); + } + + fn invalid_request_then( + &self, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl invalid_request for super::RemoteProcedures { + fn invalid_request_then( + &self, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, String>("invalid_request", InvalidRequestArgs {}, __callback); + } +} diff --git a/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs b/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs index 290212548c4..f6c007845d4 100644 --- a/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs +++ b/sdks/rust/tests/procedure-client/src/module_bindings/mod.rs @@ -1,15 +1,17 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.8.0 (commit e4aca19534d585b598f287ba5c292e8cbb531812). +// This was generated using spacetimedb cli version 1.8.0 (commit fd75c7fbf57e943785c09dc91df0697844ff4dad). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub mod insert_with_tx_commit_procedure; pub mod insert_with_tx_rollback_procedure; +pub mod invalid_request_procedure; pub mod my_table_table; pub mod my_table_type; +pub mod read_my_schema_procedure; pub mod return_enum_a_procedure; pub mod return_enum_b_procedure; pub mod return_enum_type; @@ -20,8 +22,10 @@ pub mod will_panic_procedure; pub use insert_with_tx_commit_procedure::insert_with_tx_commit; pub use insert_with_tx_rollback_procedure::insert_with_tx_rollback; +pub use invalid_request_procedure::invalid_request; pub use my_table_table::*; pub use my_table_type::MyTable; +pub use read_my_schema_procedure::read_my_schema; pub use return_enum_a_procedure::return_enum_a; pub use return_enum_b_procedure::return_enum_b; pub use return_enum_type::ReturnEnum; diff --git a/sdks/rust/tests/procedure-client/src/module_bindings/read_my_schema_procedure.rs b/sdks/rust/tests/procedure-client/src/module_bindings/read_my_schema_procedure.rs new file mode 100644 index 00000000000..eaab6c7626f --- /dev/null +++ b/sdks/rust/tests/procedure-client/src/module_bindings/read_my_schema_procedure.rs @@ -0,0 +1,40 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ReadMySchemaArgs {} + +impl __sdk::InModule for ReadMySchemaArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `read_my_schema`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait read_my_schema { + fn read_my_schema(&self) { + self.read_my_schema_then(|_, _| {}); + } + + fn read_my_schema_then( + &self, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl read_my_schema for super::RemoteProcedures { + fn read_my_schema_then( + &self, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, String>("read_my_schema", ReadMySchemaArgs {}, __callback); + } +} diff --git a/sdks/rust/tests/test.rs b/sdks/rust/tests/test.rs index 60428dd91eb..03ed168b71a 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -311,4 +311,14 @@ mod procedure { fn with_tx_rollback() { make_test("insert-with-tx-rollback").run() } + + #[test] + fn http_ok() { + make_test("procedure-http-ok").run() + } + + #[test] + fn http_err() { + make_test("procedure-http-err").run() + } }