From 3f61a2899d73f29c8f7beb0414c1a96dfeb46bbb Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 8 Apr 2026 12:52:38 -0400 Subject: [PATCH 01/47] Implement HTTP handlers / webhooks in Rust modules Codex-assisted changes to implement HTTP handlers / webhooks, based on proposal discussed elsewhere. I've done a reasonably thorough review of these changes for code quality, though they remain largely untested as of this commit. I've made a few minor changes to the LLM's output, and left TODO comments anywhere I feel more substative changes will be necessary. Most notably, we'll need more testing, documentation, and better detection of suspicious-or-invalid path components. TypeScript, C# and C++ support is also not included, as per title. There are also notes that this initial implementation uses `Vec` for its router, meaning route matching is O(num_routes), and router construction (with error checking) is O(num_routes ^ 2). I don't expect this to matter much in the short term, as I expect modules to define a pretty small number of routes. --- Cargo.lock | 3 + crates/bindings-macro/src/http.rs | 118 +++++ crates/bindings-macro/src/lib.rs | 19 + crates/bindings/src/http.rs | 303 ++++++++++++ crates/bindings/src/lib.rs | 116 +++++ crates/bindings/src/rng.rs | 29 +- crates/bindings/src/rt.rs | 85 +++- crates/client-api/Cargo.toml | 2 + crates/client-api/src/routes/database.rs | 455 +++++++++++++++++- crates/core/src/host/module_host.rs | 50 +- crates/core/src/host/v8/mod.rs | 22 +- crates/core/src/host/wasm_common.rs | 2 + .../src/host/wasm_common/module_host_actor.rs | 127 ++++- .../core/src/host/wasmtime/wasmtime_module.rs | 90 ++++ crates/lib/Cargo.toml | 1 + crates/lib/src/db/raw_def/v10.rs | 105 ++++ crates/lib/src/http.rs | 19 +- crates/primitives/src/ids.rs | 6 + crates/primitives/src/lib.rs | 4 +- crates/schema/Cargo.toml | 5 + crates/schema/src/def.rs | 119 ++++- crates/schema/src/def/validate/v10.rs | 213 +++++++- crates/schema/src/def/validate/v9.rs | 2 + crates/schema/src/error.rs | 9 + 24 files changed, 1877 insertions(+), 27 deletions(-) create mode 100644 crates/bindings-macro/src/http.rs diff --git a/Cargo.lock b/Cargo.lock index 03618187864..b2110511145 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7841,6 +7841,7 @@ dependencies = [ "futures", "headers", "http 1.3.1", + "http-body-util", "humantime", "hyper 1.7.0", "hyper-util", @@ -7872,6 +7873,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite 0.27.0", "toml 0.8.23", + "tower", "tower-http 0.5.2", "tower-layer", "tower-service", @@ -8499,6 +8501,7 @@ dependencies = [ "derive_more 0.99.20", "enum-as-inner", "enum-map", + "http 1.3.1", "indexmap 2.12.0", "insta", "itertools 0.12.1", diff --git a/crates/bindings-macro/src/http.rs b/crates/bindings-macro/src/http.rs new file mode 100644 index 00000000000..c177a5754c1 --- /dev/null +++ b/crates/bindings-macro/src/http.rs @@ -0,0 +1,118 @@ +use crate::reducer::{assert_only_lifetime_generics, extract_typed_args}; +use crate::util::ident_to_litstr; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ItemFn, ReturnType}; + +pub(crate) fn handler_impl(args: TokenStream, original_function: &ItemFn) -> syn::Result { + if !args.is_empty() { + return Err(syn::Error::new_spanned( + args, + "The `handler` attribute does not accept arguments", + )); + } + + let func_name = &original_function.sig.ident; + let vis = &original_function.vis; + let handler_name = ident_to_litstr(func_name); + + assert_only_lifetime_generics(original_function, "http handlers")?; + + // TODO(error-reporting): Prefer emitting tyck code rather than checking in the macro. + let typed_args = extract_typed_args(original_function)?; + if typed_args.len() != 2 { + return Err(syn::Error::new_spanned( + original_function.sig.clone(), + "HTTP handlers must take exactly two arguments", + )); + } + + let arg_tys = typed_args.iter().map(|arg| arg.ty.as_ref()).collect::>(); + let first_arg_ty = &arg_tys[0]; + let second_arg_ty = &arg_tys[1]; + + // TODO(error-reporting): Prefer emitting tyck code rather than checking in the macro. + let ret_ty = match &original_function.sig.output { + ReturnType::Type(_, t) => t.as_ref(), + ReturnType::Default => { + return Err(syn::Error::new_spanned( + original_function.sig.clone(), + "HTTP handlers must return `spacetimedb::http::Response`", + )); + } + }; + + let internal_ident = syn::Ident::new(&format!("__spacetimedb_http_handler_{func_name}"), func_name.span()); + let mut inner_fn = original_function.clone(); + inner_fn.sig.ident = internal_ident.clone(); + + let register_describer_symbol = format!("__preinit__20_register_http_handler_{}", handler_name.value()); + + let lifetime_params = &original_function.sig.generics; + let lifetime_where_clause = &lifetime_params.where_clause; + + let generated_describe_function = quote! { + #[unsafe(export_name = #register_describer_symbol)] + pub extern "C" fn __register_describer() { + spacetimedb::rt::register_http_handler(#handler_name, #internal_ident) + } + }; + + Ok(quote! { + #inner_fn + + #vis const #func_name: spacetimedb::http::Handler = spacetimedb::http::Handler::new(#handler_name); + + const _: () = { + #generated_describe_function + }; + + const _: () = { + // TODO(error-reporting): It should be sufficient to just cast the function to a particular `fn` type, + // rather than doing all this stuff with particular args implementing traits. + fn _assert_args #lifetime_params () #lifetime_where_clause { + let _ = <#first_arg_ty as spacetimedb::rt::HttpHandlerContextArg>::_ITEM; + let _ = <#second_arg_ty as spacetimedb::rt::HttpHandlerRequestArg>::_ITEM; + let _ = <#ret_ty as spacetimedb::rt::HttpHandlerReturn>::_ITEM; + } + }; + }) +} + +pub(crate) fn router_impl(args: TokenStream, original_function: &ItemFn) -> syn::Result { + if !args.is_empty() { + return Err(syn::Error::new_spanned( + args, + "The `router` attribute does not accept arguments", + )); + } + + if !original_function.sig.inputs.is_empty() { + return Err(syn::Error::new_spanned( + original_function.sig.clone(), + "HTTP router functions must take no arguments", + )); + } + + let func_name = &original_function.sig.ident; + let register_symbol = "__preinit__30_register_http_router"; + + Ok(quote! { + #original_function + + const _: () = { + fn _assert_router() { + // TODO(cleanup): Why two bindings here? + let _f: fn() -> spacetimedb::http::Router = #func_name; + let _ = _f; + } + }; + + const _: () = { + #[unsafe(export_name = #register_symbol)] + pub extern "C" fn __register_router() { + spacetimedb::rt::register_http_router(#func_name) + } + }; + }) +} diff --git a/crates/bindings-macro/src/lib.rs b/crates/bindings-macro/src/lib.rs index 8fa9705ca0e..a3efdd3d2f4 100644 --- a/crates/bindings-macro/src/lib.rs +++ b/crates/bindings-macro/src/lib.rs @@ -8,6 +8,7 @@ // // (private documentation for the macro authors is totally fine here and you SHOULD write that!) +mod http; mod procedure; #[proc_macro_attribute] @@ -17,6 +18,24 @@ pub fn procedure(args: StdTokenStream, item: StdTokenStream) -> StdTokenStream { procedure::procedure_impl(args, original_function) }) } + +#[proc_macro_attribute] +pub fn http_handler(args: StdTokenStream, item: StdTokenStream) -> StdTokenStream { + ok_or_compile_error(|| { + let item_ts: TokenStream = item.into(); + let original_function: ItemFn = syn::parse2(item_ts)?; + http::handler_impl(args.into(), &original_function) + }) +} + +#[proc_macro_attribute] +pub fn http_router(args: StdTokenStream, item: StdTokenStream) -> StdTokenStream { + ok_or_compile_error(|| { + let item_ts: TokenStream = item.into(); + let original_function: ItemFn = syn::parse2(item_ts)?; + http::router_impl(args.into(), &original_function) + }) +} mod reducer; #[proc_macro_attribute] diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index e31eda8dde4..89fe4cbf70f 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -6,17 +6,244 @@ //! while [`send`](HttpClient::send) allows more complex requests with headers, bodies and other methods. use bytes::Bytes; +use std::str::FromStr; use crate::{ rt::{read_bytes_source_as, read_bytes_source_into}, IterBuf, }; +use spacetimedb_lib::db::raw_def::v10::MethodOrAny; use spacetimedb_lib::{bsatn, http as st_http, TimeDuration}; pub type Request = http::Request; pub type Response = http::Response; +pub use spacetimedb_bindings_macro::{http_handler as handler, http_router as router}; + +/// Describes an HTTP handler function for use with [`Router`]. +/// +/// The [`handler`] macro will define a constant of type [`Handler`], +/// which can be used to refer to the handler function when registering it to handle a route. +#[derive(Clone, Copy)] +pub struct Handler { + name: &'static str, +} + +impl Handler { + /// Emitted by the [`handler`] macro. + /// + /// User code should not call this method. In order for a `Handler` to be valid, + /// its `name` must refer to a function registered with the SpacetimeDB host as an HTTP handler. + /// The only supported way to do this is by annotating a function with the [`handler`] macro. + #[doc(hidden)] + pub const fn new(name: &'static str) -> Self { + Self { name } + } + + pub(crate) fn name(&self) -> &'static str { + self.name + } +} + +#[derive(Clone, Default)] +pub struct Router { + routes: Vec, +} + +#[derive(Clone)] +pub(crate) struct RouteSpec { + pub method: MethodOrAny, + pub path: String, + pub handler: Handler, +} + +impl Router { + /// Returns a new, empty `Router`. + pub fn new() -> Self { + Self { routes: Vec::new() } + } + + /// Registers `handler` to handle `GET` requests at `path`. + /// + /// Panics if `self` already has a handler on this method at this path, + /// including one registered with [`Self::any`], + /// or if this path overlaps with a nested router registered by [`Self::nest`]. + /// + /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + pub fn get(self, path: impl Into, handler: Handler) -> Self { + self.add_route(MethodOrAny::Method(st_http::Method::Get), path, handler) + } + + /// Registers `handler` to handle `HEAD` requests at `path`. + /// + /// Panics if `self` already has a handler on this method at this path, + /// including one registered with [`Self::any`], + /// or if this path overlaps with a nested router registered by [`Self::nest`]. + /// + /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + pub fn head(self, path: impl Into, handler: Handler) -> Self { + self.add_route(MethodOrAny::Method(st_http::Method::Head), path, handler) + } + + /// Registers `handler` to handle `OPTIONS` requests at `path`. + /// + /// Panics if `self` already has a handler on this method at this path, + /// including one registered with [`Self::any`], + /// or if this path overlaps with a nested router registered by [`Self::nest`]. + /// + /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + pub fn options(self, path: impl Into, handler: Handler) -> Self { + self.add_route(MethodOrAny::Method(st_http::Method::Options), path, handler) + } + + /// Registers `handler` to handle `PUT` requests at `path`. + /// + /// Panics if `self` already has a handler on this method at this path, + /// including one registered with [`Self::any`], + /// or if this path overlaps with a nested router registered by [`Self::nest`]. + /// + /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + pub fn put(self, path: impl Into, handler: Handler) -> Self { + self.add_route(MethodOrAny::Method(st_http::Method::Put), path, handler) + } + + /// Registers `handler` to handle `DELETE` requests at `path`. + /// + /// Panics if `self` already has a handler on this method at this path, + /// including one registered with [`Self::any`], + /// or if this path overlaps with a nested router registered by [`Self::nest`]. + /// + /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + pub fn delete(self, path: impl Into, handler: Handler) -> Self { + self.add_route(MethodOrAny::Method(st_http::Method::Delete), path, handler) + } + + /// Registers `handler` to handle `POST` requests at `path`. + /// + /// Panics if `self` already has a handler on this method at this path, + /// including one registered with [`Self::any`], + /// or if this path overlaps with a nested router registered by [`Self::nest`]. + /// + /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + pub fn post(self, path: impl Into, handler: Handler) -> Self { + self.add_route(MethodOrAny::Method(st_http::Method::Post), path, handler) + } + + /// Registers `handler` to handle `PATCH` requests at `path`. + /// + /// Panics if `self` already has a handler on this method at this path, + /// including one registered with [`Self::any`], + /// or if this path overlaps with a nested router registered by [`Self::nest`]. + /// + /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + pub fn patch(self, path: impl Into, handler: Handler) -> Self { + self.add_route(MethodOrAny::Method(st_http::Method::Patch), path, handler) + } + + /// Registers `handler` to handle requests of any HTTP method at `path`. + /// + /// Panics if `self` already has a handler on at least one method at this path, + /// or if this path overlaps with a nested router registered by [`Self::nest`]. + /// + /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + pub fn any(self, path: impl Into, handler: Handler) -> Self { + self.add_route(MethodOrAny::Any, path, handler) + } + + /// Causes requests which start with `path` to be processed by `sub_router`. + /// + /// `sub_router` will be used by stripping the leading `path` from the path of the request. + /// + /// Panics if `self` already has any handlers registered on paths which start with `path`. + /// + /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + pub fn nest(self, path: impl Into, sub_router: Self) -> Self { + let path = path.into(); + assert_valid_path(&path); + + // FIXME: either this check is too restrictive, or the checks in the other methods are too lenient. + // Do we want it to be the case that the `sub_router` effectively takes ownership of the whole route below `path`, + // or just the routes it actually contains? + if self.routes.iter().any(|route| route.path.starts_with(&path)) { + panic!("Cannot nest router at `{path}`; existing routes overlap with nested path"); + } + + let mut merged = self; + for route in sub_router.routes { + let nested_path = join_paths(&path, &route.path); + merged = merged.add_route(route.method, nested_path, route.handler); + } + merged + } + + /// Combines all of the routes in `self` and `other_router` into a single [`Router`]. + /// + /// Panics if any of the routes in `self` conflict with any of the routes in `other_router`. + pub fn merge(self, other_router: Self) -> Self { + let mut merged = self; + for route in other_router.routes { + merged = merged.add_route(route.method, route.path, route.handler); + } + merged + } + + pub(crate) fn into_routes(self) -> Vec { + self.routes + } + + fn add_route(mut self, method: MethodOrAny, path: impl Into, handler: Handler) -> Self { + let path = path.into(); + assert_valid_path(&path); + + let candidate = RouteSpec { + method: method.clone(), + path: path.clone(), + handler, + }; + + // TODO(perf): Adding a route is O(n), which means that building a router is O(n^2) + if self.routes.iter().any(|route| routes_overlap(route, &candidate)) { + panic!("Route conflict for `{path}`"); + } + + self.routes.push(candidate); + self + } +} + +fn join_paths(prefix: &str, suffix: &str) -> String { + if prefix == "/" { + return suffix.to_string(); + } + if suffix == "/" { + return prefix.to_string(); + } + let prefix = prefix.trim_end_matches('/'); + let suffix = suffix.trim_start_matches('/'); + format!("{prefix}/{suffix}") +} + +fn assert_valid_path(path: &str) { + if !path.starts_with('/') { + panic!("Route paths must start with `/`: {path}"); + } + // TODO: detect more suspicious characters. https://stackoverflow.com/a/695467 seems like a reasonable set. + if path.contains('?') || path.contains('#') { + panic!("Route paths must not include `?` or `#`: {path}"); + } + if http::uri::PathAndQuery::from_str(path).is_err() { + panic!("Route path is not a valid URL path: {path}"); + } +} + +fn routes_overlap(a: &RouteSpec, b: &RouteSpec) -> bool { + if a.path != b.path { + return false; + } + matches!(a.method, MethodOrAny::Any) || matches!(b.method, MethodOrAny::Any) || a.method == b.method +} + /// Allows performing HTTP requests via [`HttpClient::send`] and [`HttpClient::get`]. /// /// Access an `HttpClient` from within [procedures](crate::procedure) @@ -199,6 +426,82 @@ fn convert_response(response: st_http::Response) -> http::Result http::Request { + let st_http::RequestAndBody { request, body } = req; + let st_http::Request { + method, + headers, + timeout: _, + uri, + version, + } = request; + + let method = match method { + st_http::Method::Get => http::Method::GET, + st_http::Method::Head => http::Method::HEAD, + st_http::Method::Post => http::Method::POST, + st_http::Method::Put => http::Method::PUT, + st_http::Method::Delete => http::Method::DELETE, + st_http::Method::Connect => http::Method::CONNECT, + st_http::Method::Options => http::Method::OPTIONS, + st_http::Method::Trace => http::Method::TRACE, + st_http::Method::Patch => http::Method::PATCH, + st_http::Method::Extension(ext) => { + http::Method::from_bytes(ext.as_bytes()).expect("Invalid HTTP method from host") + } + }; + + let request = http::Request::builder() + .method(method) + .uri(http::Uri::from_str(&uri).expect("Invalid URI from host")) + .body(Body::from_bytes(body)) + .expect("Failed to build request"); + + let (mut parts, body) = request.into_parts(); + parts.version = match version { + st_http::Version::Http09 => http::Version::HTTP_09, + st_http::Version::Http10 => http::Version::HTTP_10, + st_http::Version::Http11 => http::Version::HTTP_11, + st_http::Version::Http2 => http::Version::HTTP_2, + st_http::Version::Http3 => http::Version::HTTP_3, + }; + parts.headers = headers + .into_iter() + .map(|(k, v)| { + let name = http::HeaderName::from_bytes(k.as_bytes()).expect("Invalid header name from host"); + let value = http::HeaderValue::from_bytes(v.as_ref()).expect("Invalid header value from host"); + (name, value) + }) + .collect(); + + http::Request::from_parts(parts, body) +} + +pub(crate) fn response_into_wire(response: http::Response) -> st_http::ResponseAndBody { + let (parts, body) = response.into_parts(); + let st_response = st_http::Response { + headers: parts + .headers + .into_iter() + .map(|(k, v)| (k.map(|k| k.as_str().into()), v.as_bytes().into())) + .collect(), + version: match parts.version { + http::Version::HTTP_09 => st_http::Version::Http09, + http::Version::HTTP_10 => st_http::Version::Http10, + http::Version::HTTP_11 => st_http::Version::Http11, + http::Version::HTTP_2 => st_http::Version::Http2, + http::Version::HTTP_3 => st_http::Version::Http3, + _ => unreachable!("Unknown HTTP version: {:?}", parts.version), + }, + code: parts.status.as_u16(), + }; + + st_http::ResponseAndBody { + response: st_response, + body: body.into_bytes(), + } +} + /// Represents the body of an HTTP request or response. pub struct Body { inner: BodyInner, diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 9e02a3a97f0..ae453b1ad12 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -1444,6 +1444,122 @@ impl ProcedureContext { } } +/// The context that any HTTP handler is provided with. +/// +/// Each HTTP handler must accept `&mut HandlerContext` as its first argument. +/// +/// Includes the time of invocation and exposes methods for running transactions +/// and performing side-effecting operations. +#[non_exhaustive] +#[cfg(feature = "unstable")] +pub struct HandlerContext { + /// The time at which the handler was started. + pub timestamp: Timestamp, + + /// Methods for performing HTTP requests. + pub http: crate::http::HttpClient, + + #[cfg(feature = "rand08")] + rng: std::cell::OnceCell, + + /// A counter used for generating UUIDv7 values. + /// **Note:** must be 0..=u32::MAX + #[cfg(feature = "rand")] + counter_uuid: Cell, +} + +#[cfg(feature = "unstable")] +impl HandlerContext { + fn new(timestamp: Timestamp) -> Self { + Self { + timestamp, + http: http::HttpClient {}, + #[cfg(feature = "rand08")] + rng: std::cell::OnceCell::new(), + #[cfg(feature = "rand")] + counter_uuid: Cell::new(0), + } + } + + /// Read the current module's [`Identity`]. + pub fn identity(&self) -> Identity { + Identity::from_byte_array(spacetimedb_bindings_sys::identity()) + } + + /// Acquire a mutable transaction and execute `body` with read-write access. + #[cfg(feature = "unstable")] + pub fn with_tx(&mut self, body: impl Fn(&TxContext) -> T) -> T { + use core::convert::Infallible; + match self.try_with_tx::(|tx| Ok(body(tx))) { + Ok(v) => v, + Err(e) => match e {}, + } + } + + /// Acquire a mutable transaction and execute `body` with read-write access. + #[cfg(feature = "unstable")] + pub fn try_with_tx(&mut self, body: impl Fn(&TxContext) -> Result) -> Result { + let abort = || { + sys::procedure::procedure_abort_mut_tx() + .expect("should have a pending mutable anon tx as `procedure_start_mut_tx` preceded") + }; + + let run = || { + let timestamp = sys::procedure::procedure_start_mut_tx() + .expect("holding `&mut HandlerContext`, so should not be in a tx already; called manually elsewhere?"); + let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp); + + // Use the internal auth context (no external caller identity). + let tx = ReducerContext::new(Local {}, Identity::ZERO, None, timestamp); + let tx = TxContext(tx); + + struct DoOnDrop(F); + impl Drop for DoOnDrop { + fn drop(&mut self) { + (self.0)(); + } + } + let abort_guard = DoOnDrop(abort); + let res = body(&tx); + core::mem::forget(abort_guard); + res + }; + + let mut res = run(); + + match res { + Ok(_) if sys::procedure::procedure_commit_mut_tx().is_err() => { + log::warn!("committing anonymous transaction failed"); + res = run(); + match res { + Ok(_) => sys::procedure::procedure_commit_mut_tx().expect("transaction retry failed again"), + Err(_) => abort(), + } + } + Ok(_) => {} + Err(_) => abort(), + } + + res + } + + /// Create a new random [`Uuid`] `v4` using the built-in RNG. + #[cfg(all(feature = "unstable", feature = "rand"))] + pub fn new_uuid_v4(&self) -> anyhow::Result { + let mut bytes = [0u8; 16]; + self.rng().try_fill_bytes(&mut bytes)?; + Ok(Uuid::from_random_bytes_v4(bytes)) + } + + /// Create a new sortable [`Uuid`] `v7` using the built-in RNG, counter and timestamp. + #[cfg(all(feature = "unstable", feature = "rand"))] + pub fn new_uuid_v7(&self) -> anyhow::Result { + let mut random_bytes = [0u8; 4]; + self.rng().try_fill_bytes(&mut random_bytes)?; + Uuid::from_counter_v7(&self.counter_uuid, self.timestamp, &random_bytes) + } +} + /// A handle on a database with a particular table schema. pub trait DbContext { /// A view into the tables of a database. diff --git a/crates/bindings/src/rng.rs b/crates/bindings/src/rng.rs index 4066abfeb20..bbb1ec6d04b 100644 --- a/crates/bindings/src/rng.rs +++ b/crates/bindings/src/rng.rs @@ -1,6 +1,6 @@ -#[cfg(feature = "unstable")] -use crate::ProcedureContext; use crate::{rand, ReducerContext}; +#[cfg(feature = "unstable")] +use crate::{HandlerContext, ProcedureContext}; use core::cell::UnsafeCell; use core::marker::PhantomData; use rand::distributions::{Distribution, Standard}; @@ -96,6 +96,31 @@ impl ProcedureContext { } } +#[cfg(feature = "unstable")] +impl HandlerContext { + /// Generates a random value. + /// + /// Similar to [`rand::random()`], but using [`StdbRng`] instead. + /// + /// See also [`HandlerContext::rng()`]. + #[cfg(feature = "unstable")] + pub fn random(&self) -> T + where + Standard: Distribution, + { + Standard.sample(&mut self.rng()) + } + + /// Retrieve the random number generator for this handler invocation, + /// seeded by the handler timestamp. + /// + /// If you only need a single random value, you can use [`HandlerContext::random()`]. + #[cfg(feature = "unstable")] + pub fn rng(&self) -> &StdbRng { + self.rng.get_or_init(|| StdbRng::seed_from_ts(self.timestamp)) + } +} + /// A reference to the random number generator for this reducer call. /// /// An instance can be obtained via [`ReducerContext::rng()`]. Import diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index d6d55eba5f4..2f0c6759a8f 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -2,7 +2,9 @@ use crate::query_builder::{FromWhere, HasCols, LeftSemiJoin, RawQuery, RightSemiJoin, Table as QbTable}; use crate::table::IndexAlgo; -use crate::{sys, AnonymousViewContext, IterBuf, ReducerContext, ReducerResult, SpacetimeType, Table, ViewContext}; +use crate::{ + http, sys, AnonymousViewContext, IterBuf, ReducerContext, ReducerResult, SpacetimeType, Table, ViewContext, +}; use spacetimedb_lib::bsatn::EncodeError; use spacetimedb_lib::db::raw_def::v10::{ CaseConversionPolicy, ExplicitNames as RawExplicitNames, RawModuleDefV10Builder, @@ -22,7 +24,7 @@ use std::sync::{Mutex, OnceLock}; pub use sys::raw::{BytesSink, BytesSource}; #[cfg(feature = "unstable")] -use crate::{ProcedureContext, ProcedureResult}; +use crate::{HandlerContext, ProcedureContext, ProcedureResult}; pub trait IntoVec { fn into_vec(self) -> Vec; @@ -287,6 +289,36 @@ pub trait ProcedureArg { #[cfg(feature = "unstable")] impl ProcedureArg for T {} +#[cfg(feature = "unstable")] +#[diagnostic::on_unimplemented( + message = "the first argument of an HTTP handler must be `&mut HandlerContext`", + label = "first argument must be `&mut HandlerContext`" +)] +pub trait HttpHandlerContextArg { + #[doc(hidden)] + const _ITEM: () = (); +} +#[cfg(feature = "unstable")] +impl HttpHandlerContextArg for &mut HandlerContext {} + +#[cfg(feature = "unstable")] +#[diagnostic::on_unimplemented(message = "the second argument of an HTTP handler must be `spacetimedb::http::Request`")] +pub trait HttpHandlerRequestArg { + #[doc(hidden)] + const _ITEM: () = (); +} +#[cfg(feature = "unstable")] +impl HttpHandlerRequestArg for crate::http::Request {} + +#[cfg(feature = "unstable")] +#[diagnostic::on_unimplemented(message = "HTTP handlers must return `spacetimedb::http::Response`")] +pub trait HttpHandlerReturn { + #[doc(hidden)] + const _ITEM: () = (); +} +#[cfg(feature = "unstable")] +impl HttpHandlerReturn for crate::http::Response {} + #[diagnostic::on_unimplemented( message = "The first parameter of a `#[view]` must be `&ViewContext` or `&AnonymousViewContext`" )] @@ -832,6 +864,26 @@ where }) } +#[cfg(feature = "unstable")] +pub fn register_http_handler(name: &'static str, handler: HttpHandlerFn) { + register_describer(move |module| { + module.inner.add_http_handler(name); + module.http_handlers.push(handler); + }) +} + +#[cfg(feature = "unstable")] +pub fn register_http_router(build: fn() -> crate::http::Router) { + register_describer(move |module| { + let router = build(); + for route in router.into_routes() { + module + .inner + .add_http_route(route.handler.name(), route.method, route.path); + } + }) +} + /// Registers a describer for the anonymous view `I` with arguments `A` and return type `Vec`. pub fn register_anonymous_view<'a, A, I, T>(_: impl AnonymousView<'a, A, T>) where @@ -884,6 +936,9 @@ pub struct ModuleBuilder { /// The procedures of the module. #[cfg(feature = "unstable")] procedures: Vec, + /// The HTTP handlers of the module. + #[cfg(feature = "unstable")] + http_handlers: Vec, /// The client specific views of the module. views: Vec, /// The anonymous views of the module. @@ -903,6 +958,11 @@ pub type ProcedureFn = fn(&mut ProcedureContext, &[u8]) -> ProcedureResult; #[cfg(feature = "unstable")] static PROCEDURES: OnceLock> = OnceLock::new(); +#[cfg(feature = "unstable")] +pub type HttpHandlerFn = fn(&mut HandlerContext, crate::http::Request) -> crate::http::Response; +#[cfg(feature = "unstable")] +static HTTP_HANDLERS: OnceLock> = OnceLock::new(); + /// A view function takes in `(ViewContext, Args)` and returns a Vec of bytes. pub type ViewFn = fn(ViewContext, &[u8]) -> Vec; static VIEWS: OnceLock> = OnceLock::new(); @@ -943,6 +1003,8 @@ extern "C" fn __describe_module__(description: BytesSink) { REDUCERS.set(module.reducers).ok().unwrap(); #[cfg(feature = "unstable")] PROCEDURES.set(module.procedures).ok().unwrap(); + #[cfg(feature = "unstable")] + HTTP_HANDLERS.set(module.http_handlers).ok().unwrap(); VIEWS.set(module.views).ok().unwrap(); ANONYMOUS_VIEWS.set(module.views_anon).ok().unwrap(); @@ -1116,6 +1178,25 @@ extern "C" fn __call_procedure__( 0 } +/// Called by the host to execute an HTTP handler. +#[cfg(feature = "unstable")] +#[unsafe(no_mangle)] +extern "C" fn __call_http_handler__(id: usize, timestamp: u64, request: BytesSource, result_sink: BytesSink) -> i16 { + let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp as i64); + let mut ctx = HandlerContext::new(timestamp); + + let handlers = HTTP_HANDLERS.get().unwrap(); + let request = read_bytes_source_as::(request); + let request = http::request_from_wire(request); + + let response = handlers[id](&mut ctx, request); + let response = http::response_into_wire(response); + let bytes = bsatn::to_vec(&response).expect("failed to serialize http response"); + write_to_sink(result_sink, &bytes); + + 0 +} + /// Called by the host to execute an anonymous view. /// /// The `args` is a `BytesSource`, registered on the host side, diff --git a/crates/client-api/Cargo.toml b/crates/client-api/Cargo.toml index a3da402e734..afbb5a2a340 100644 --- a/crates/client-api/Cargo.toml +++ b/crates/client-api/Cargo.toml @@ -16,6 +16,7 @@ spacetimedb-paths.workspace = true spacetimedb-schema.workspace = true base64.workspace = true +http-body-util.workspace = true tokio = { version = "1.2", features = ["full"] } lazy_static = "1.4.0" log = "0.4.4" @@ -60,6 +61,7 @@ thiserror.workspace = true jemalloc_pprof.workspace = true [dev-dependencies] + tower = "0.5" jsonwebtoken.workspace = true pretty_assertions = { workspace = true, features = ["unstable"] } proptest.workspace = true diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 6b753a9c8fd..83b9e87320d 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -15,13 +15,14 @@ use crate::{ NodeDelegate, Unauthorized, }; use axum::body::{Body, Bytes}; -use axum::extract::{Path, Query, State}; +use axum::extract::{Path, Query, Request, State}; use axum::response::{ErrorResponse, IntoResponse}; use axum::routing::MethodRouter; use axum::Extension; use axum_extra::TypedHeader; use futures::TryStreamExt; use http::StatusCode; +use http_body_util::BodyExt; use log::{info, warn}; use serde::Deserialize; use spacetimedb::database_logger::DatabaseLogger; @@ -39,6 +40,7 @@ use spacetimedb_client_api_messages::name::{ }; use spacetimedb_lib::db::raw_def::v10::RawModuleDefV10; use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9; +use spacetimedb_lib::http as st_http; use spacetimedb_lib::{sats, AlgebraicValue, Hash, ProductValue, Timestamp}; use spacetimedb_schema::auto_migrate::{ MigrationPolicy as SchemaMigrationPolicy, MigrationToken, PrettyPrintStyle as AutoMigratePrettyPrintStyle, @@ -216,6 +218,94 @@ pub async fn call( } } +#[derive(Deserialize)] +pub struct HttpRouteRootParams { + name_or_identity: NameOrIdentity, +} + +#[derive(Deserialize)] +pub struct HttpRouteParams { + name_or_identity: NameOrIdentity, + path: String, +} + +pub async fn handle_http_route_root( + State(worker_ctx): State, + Path(HttpRouteRootParams { name_or_identity }): Path, + request: Request, +) -> axum::response::Result { + handle_http_route_impl(worker_ctx, name_or_identity, None, request).await +} + +pub async fn handle_http_route( + State(worker_ctx): State, + Path(HttpRouteParams { name_or_identity, path }): Path, + request: Request, +) -> axum::response::Result { + handle_http_route_impl(worker_ctx, name_or_identity, Some(path), request).await +} + +/// Error response body for unknown user-defined HTTP route. +const NO_SUCH_ROUTE: &str = "Database has not registered a handler for this route"; + +async fn handle_http_route_impl( + worker_ctx: S, + name_or_identity: NameOrIdentity, + path: Option, + request: Request, +) -> axum::response::Result { + let handler_path = match path.as_deref() { + Some("") | None => "/".to_string(), + Some(path) => format!("/{path}"), + }; + + let (parts, body) = request.into_parts(); + let st_method = http_method_to_st(&parts.method); + + let (module, _database) = find_module_and_database(&worker_ctx, name_or_identity).await?; + let module_def = &module.info().module_def; + + let Some((handler_id, _handler_def, _route_def)) = module_def.match_http_route(&st_method, &handler_path) else { + return Ok(StatusCode::NOT_FOUND.into_response()); + }; + + let body = body.collect().await.map_err(log_and_500)?.to_bytes(); + let request = st_http::RequestAndBody { + request: st_http::Request { + method: st_method.clone(), + headers: headers_to_st(parts.headers), + timeout: None, + uri: parts.uri.to_string(), + version: http_version_to_st(parts.version), + }, + body, + }; + + let response = match module.call_http_handler(handler_id, request).await { + Ok(response) => response, + Err(spacetimedb::host::module_host::HttpHandlerCallError::NoSuchHandler) => { + return Ok((StatusCode::NOT_FOUND, NO_SUCH_ROUTE).into_response()); + } + // TODO(v8-http-handlers): Remove. + Err(spacetimedb::host::module_host::HttpHandlerCallError::UnsupportedHostType) => { + return Err(( + StatusCode::NOT_IMPLEMENTED, + "HTTP handlers are not supported for this module", + ) + .into()); + } + Err(spacetimedb::host::module_host::HttpHandlerCallError::NoSuchModule(_)) => { + return Err(NO_SUCH_DATABASE.into()); + } + Err(spacetimedb::host::module_host::HttpHandlerCallError::InternalError(err)) => { + return Err((StatusCode::INTERNAL_SERVER_ERROR, err).into()); + } + }; + + let response = response_from_st(response)?; + Ok(response.into_response()) +} + fn assert_content_type_json(content_type: headers::ContentType) -> axum::response::Result<()> { if content_type != headers::ContentType::json() { Err(axum::extract::rejection::MissingJsonContentType::default().into()) @@ -224,6 +314,61 @@ fn assert_content_type_json(content_type: headers::ContentType) -> axum::respons } } +fn http_method_to_st(method: &http::Method) -> st_http::Method { + match *method { + http::Method::GET => st_http::Method::Get, + http::Method::HEAD => st_http::Method::Head, + http::Method::POST => st_http::Method::Post, + http::Method::PUT => st_http::Method::Put, + http::Method::DELETE => st_http::Method::Delete, + http::Method::CONNECT => st_http::Method::Connect, + http::Method::OPTIONS => st_http::Method::Options, + http::Method::TRACE => st_http::Method::Trace, + http::Method::PATCH => st_http::Method::Patch, + _ => st_http::Method::Extension(method.to_string()), + } +} + +fn http_version_to_st(version: http::Version) -> st_http::Version { + match version { + http::Version::HTTP_09 => st_http::Version::Http09, + http::Version::HTTP_10 => st_http::Version::Http10, + http::Version::HTTP_11 => st_http::Version::Http11, + http::Version::HTTP_2 => st_http::Version::Http2, + http::Version::HTTP_3 => st_http::Version::Http3, + _ => unreachable!("unknown HTTP version: {version:?}"), + } +} + +fn headers_to_st(headers: http::HeaderMap) -> st_http::Headers { + headers + .into_iter() + .map(|(k, v)| (k.map(|k| k.as_str().into()), v.as_bytes().into())) + .collect() +} + +fn response_from_st(response: st_http::ResponseAndBody) -> axum::response::Result> { + let st_http::ResponseAndBody { response, body } = response; + let st_http::Response { headers, version, code } = response; + + let mut response = http::Response::new(Body::from(body)); + *response.version_mut() = match version { + st_http::Version::Http09 => http::Version::HTTP_09, + st_http::Version::Http10 => http::Version::HTTP_10, + st_http::Version::Http11 => http::Version::HTTP_11, + st_http::Version::Http2 => http::Version::HTTP_2, + st_http::Version::Http3 => http::Version::HTTP_3, + }; + *response.status_mut() = http::StatusCode::from_u16(code).map_err(log_and_500)?; + for (name, value) in headers.into_iter() { + let name = http::HeaderName::from_bytes(name.as_bytes()).map_err(log_and_500)?; + let value = http::HeaderValue::from_bytes(&value).map_err(log_and_500)?; + response.headers_mut().append(name, value); + } + + Ok(response) +} + fn reducer_outcome_response( owner_identity: &Identity, reducer: &str, @@ -1235,6 +1380,8 @@ where S: NodeDelegate + ControlStateDelegate + Authorization + Clone + 'static, { pub fn into_router(self, ctx: S) -> axum::Router { + use axum::routing::any; + let db_router = axum::Router::::new() .route("/", self.db_put) .route("/", self.db_get) @@ -1252,9 +1399,311 @@ where .route("/pre_publish", self.pre_publish) .route("/reset", self.db_reset); + let authed_router = axum::Router::new() + .nest("/:name_or_identity", db_router) + .route_layer(axum::middleware::from_fn_with_state(ctx, anon_auth_middleware::)); + + // NOTE: HTTP route handlers are intentionally unauthenticated so they can accept + // webhooks and other requests from outside the SpacetimeDB auth ecosystem. + // This route must bypass `anon_auth_middleware` entirely so invalid/missing + // Authorization headers do not trigger early rejection or attach SpacetimeAuth. + // Keep these routes merged separately from the authenticated database router. + let http_route_router = axum::Router::::new() + .route("/:name_or_identity/route", any(handle_http_route_root::)) + .route("/:name_or_identity/route/*path", any(handle_http_route::)); + axum::Router::new() .route("/", self.root_post) - .nest("/:name_or_identity", db_router) - .route_layer(axum::middleware::from_fn_with_state(ctx, anon_auth_middleware::)) + .merge(authed_router) + .merge(http_route_router) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::JwtAuthProvider; + use crate::routes::subscribe::{HasWebSocketOptions, WebSocketOptions}; + use crate::{ + Action, Authorization, ControlStateReadAccess, ControlStateWriteAccess, MaybeMisdirected, Unauthorized, + }; + use async_trait::async_trait; + use axum::body::Body; + use http::Request; + use spacetimedb::auth::identity::{JwtError, JwtErrorKind, SpacetimeIdentityClaims}; + use spacetimedb::auth::token_validation::{TokenSigner, TokenValidationError, TokenValidator}; + use spacetimedb::client::ClientActorIndex; + use spacetimedb::energy::{EnergyBalance, EnergyQuanta}; + use spacetimedb::identity::AuthCtx; + use spacetimedb::messages::control_db::{Database, Node, Replica}; + use spacetimedb_client_api_messages::name::{ + DomainName, InsertDomainResult, RegisterTldResult, SetDomainsResult, Tld, + }; + use spacetimedb_paths::server::ModuleLogsDir; + use spacetimedb_paths::FromPathUnchecked; + use spacetimedb_schema::auto_migrate::{MigrationPolicy, PrettyPrintStyle}; + use tower::util::ServiceExt; + #[derive(Clone, Default)] + struct DummyValidator; + + #[async_trait] + impl TokenValidator for DummyValidator { + async fn validate_token(&self, _token: &str) -> Result { + Err(TokenValidationError::Other(anyhow::anyhow!("unused"))) + } + } + + #[derive(Clone)] + struct DummyJwtProvider { + validator: DummyValidator, + } + + impl TokenSigner for DummyJwtProvider { + fn sign(&self, _claims: &T) -> Result { + Err(JwtError::from(JwtErrorKind::InvalidSignature)) + } + } + + impl JwtAuthProvider for DummyJwtProvider { + type TV = DummyValidator; + + fn validator(&self) -> &Self::TV { + &self.validator + } + + fn local_issuer(&self) -> &str { + "test" + } + + fn public_key_bytes(&self) -> &[u8] { + b"" + } + } + + #[derive(Clone)] + struct DummyState { + jwt: DummyJwtProvider, + client_actor_index: std::sync::Arc, + module_logs_dir: ModuleLogsDir, + } + + impl DummyState { + fn new() -> Self { + Self { + jwt: DummyJwtProvider { + validator: DummyValidator, + }, + client_actor_index: std::sync::Arc::new(ClientActorIndex::new()), + module_logs_dir: ModuleLogsDir::from_path_unchecked(std::env::temp_dir()), + } + } + } + + impl HasWebSocketOptions for DummyState { + fn websocket_options(&self) -> WebSocketOptions { + WebSocketOptions::default() + } + } + + #[async_trait] + impl NodeDelegate for DummyState { + type GetLeaderHostError = DummyLeaderError; + + fn gather_metrics(&self) -> Vec { + Vec::new() + } + + fn client_actor_index(&self) -> &ClientActorIndex { + self.client_actor_index.as_ref() + } + + type JwtAuthProviderT = DummyJwtProvider; + fn jwt_auth_provider(&self) -> &Self::JwtAuthProviderT { + &self.jwt + } + + async fn leader(&self, _database_id: u64) -> Result { + Err(DummyLeaderError) + } + + fn module_logs_dir(&self, _replica_id: u64) -> ModuleLogsDir { + self.module_logs_dir.clone() + } + } + + #[derive(Debug)] + struct DummyLeaderError; + + impl MaybeMisdirected for DummyLeaderError { + fn is_misdirected(&self) -> bool { + false + } + } + + impl std::fmt::Display for DummyLeaderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("dummy leader error") + } + } + + impl From for ErrorResponse { + fn from(_: DummyLeaderError) -> Self { + (StatusCode::INTERNAL_SERVER_ERROR, "dummy leader error").into() + } + } + + #[async_trait] + impl ControlStateReadAccess for DummyState { + async fn get_node_id(&self) -> Option { + None + } + async fn get_node_by_id(&self, _node_id: u64) -> anyhow::Result> { + Ok(None) + } + async fn get_nodes(&self) -> anyhow::Result> { + Ok(Vec::new()) + } + async fn get_database_by_id(&self, _id: u64) -> anyhow::Result> { + Ok(None) + } + async fn get_database_by_identity(&self, _database_identity: &Identity) -> anyhow::Result> { + Ok(None) + } + async fn get_databases(&self) -> anyhow::Result> { + Ok(Vec::new()) + } + async fn get_replica_by_id(&self, _id: u64) -> anyhow::Result> { + Ok(None) + } + async fn get_replicas(&self) -> anyhow::Result> { + Ok(Vec::new()) + } + async fn get_leader_replica_by_database(&self, _database_id: u64) -> Option { + None + } + async fn get_energy_balance(&self, _identity: &Identity) -> anyhow::Result> { + Ok(None) + } + async fn lookup_database_identity(&self, _domain: &str) -> anyhow::Result> { + Ok(None) + } + async fn reverse_lookup(&self, _database_identity: &Identity) -> anyhow::Result> { + Ok(Vec::new()) + } + async fn lookup_namespace_owner(&self, _name: &str) -> anyhow::Result> { + Ok(None) + } + } + + #[async_trait] + impl ControlStateWriteAccess for DummyState { + async fn publish_database( + &self, + _publisher: &Identity, + _spec: DatabaseDef, + _policy: MigrationPolicy, + ) -> anyhow::Result> { + Err(anyhow::anyhow!("unused")) + } + + async fn migrate_plan( + &self, + _spec: DatabaseDef, + _style: PrettyPrintStyle, + ) -> anyhow::Result { + Err(anyhow::anyhow!("unused")) + } + + async fn delete_database( + &self, + _caller_identity: &Identity, + _database_identity: &Identity, + ) -> anyhow::Result<()> { + Err(anyhow::anyhow!("unused")) + } + + async fn reset_database(&self, _caller_identity: &Identity, _spec: DatabaseResetDef) -> anyhow::Result<()> { + Err(anyhow::anyhow!("unused")) + } + + async fn add_energy(&self, _identity: &Identity, _amount: EnergyQuanta) -> anyhow::Result<()> { + Err(anyhow::anyhow!("unused")) + } + + async fn withdraw_energy(&self, _identity: &Identity, _amount: EnergyQuanta) -> anyhow::Result<()> { + Err(anyhow::anyhow!("unused")) + } + + async fn register_tld(&self, _identity: &Identity, _tld: Tld) -> anyhow::Result { + Err(anyhow::anyhow!("unused")) + } + + async fn create_dns_record( + &self, + _owner_identity: &Identity, + _domain: &DomainName, + _database_identity: &Identity, + ) -> anyhow::Result { + Err(anyhow::anyhow!("unused")) + } + + async fn replace_dns_records( + &self, + _database_identity: &Identity, + _owner_identity: &Identity, + _domain_names: &[DomainName], + ) -> anyhow::Result { + Err(anyhow::anyhow!("unused")) + } + } + + impl Authorization for DummyState { + fn authorize_action( + &self, + _subject: Identity, + _database: Identity, + _action: Action, + ) -> impl std::future::Future> + Send { + async { Err(Unauthorized::InternalError(anyhow::anyhow!("unused"))) } + } + + fn authorize_sql( + &self, + _subject: Identity, + _database: Identity, + ) -> impl std::future::Future> + Send { + async { Err(Unauthorized::InternalError(anyhow::anyhow!("unused"))) } + } + } + + /// Tests that requests to user-defined routes under `/database/:name-or-identity/routes` + /// bypass the usual SpacetimeDB auth middleware, + /// and accept requests with `Authorization` headers that SpacetimeDB would treat as malformed. + /// + /// This behavior is necessary to allow HTTP handlers to accept requests from non-SpacetimeDB-ecosystem clients, + /// e.g. for the purposes of handling webhooks. + #[tokio::test] + async fn http_route_bypasses_auth_middleware() { + let state = DummyState::new(); + let app = DatabaseRoutes::::default() + .into_router(state.clone()) + .with_state(state); + + let request = Request::builder() + .method(http::Method::POST) + .uri("/not-a-database/route/health") + .header(http::header::AUTHORIZATION, "Bearer not-a-jwt") + .body(Body::from("payload")) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let body = response.into_body().collect().await.unwrap().to_bytes(); + // We'll get this error message out of the stack: + // - `find_module_and_database` + // - `find_leader_and_database` + // - `name_or_identity.resolve(worker_ctx)` -> `NameOrIdentity::resolve` + assert_eq!(body, "`not-a-database` not found"); } } diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 6ed0f0ec97c..1ae2372df41 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -53,10 +53,11 @@ use spacetimedb_execution::pipelined::{PipelinedProject, ViewProject}; use spacetimedb_execution::RelValue; use spacetimedb_expr::expr::CollectViews; use spacetimedb_lib::db::raw_def::v9::Lifecycle; +use spacetimedb_lib::http::{RequestAndBody, ResponseAndBody}; use spacetimedb_lib::identity::{AuthCtx, RequestId}; use spacetimedb_lib::metrics::ExecutionMetrics; use spacetimedb_lib::{ConnectionId, Timestamp}; -use spacetimedb_primitives::{ArgId, ProcedureId, TableId, ViewFnPtr, ViewId}; +use spacetimedb_primitives::{ArgId, HttpHandlerId, ProcedureId, TableId, ViewFnPtr, ViewId}; use spacetimedb_query::compile_subscription; use spacetimedb_sats::raw_identifier::RawIdentifier; use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, ProductValue}; @@ -743,6 +744,12 @@ impl CallProcedureParams { } } +pub struct CallHttpHandlerParams { + pub timestamp: Timestamp, + pub handler_id: HttpHandlerId, + pub request: RequestAndBody, +} + /// Holds a [`Module`] and a set of [`Instance`]s from it, /// and allocates the [`Instance`]s to be used for function calls. /// @@ -1028,6 +1035,21 @@ pub enum ProcedureCallError { InternalError(String), } +#[derive(thiserror::Error, Debug)] +pub enum HttpHandlerCallError { + #[error(transparent)] + NoSuchModule(#[from] NoSuchModule), + #[error("no such http handler")] + NoSuchHandler, + + // TODO(v8-http-handlers): Remove this error variant. + #[error("http handlers are not supported for this host type")] + UnsupportedHostType, + + #[error("The module instance encountered a fatal error: {0}")] + InternalError(String), +} + #[derive(thiserror::Error, Debug)] pub enum InitDatabaseError { #[error(transparent)] @@ -1923,6 +1945,32 @@ impl ModuleHost { .await } + pub async fn call_http_handler( + &self, + handler_id: HttpHandlerId, + request: RequestAndBody, + ) -> Result { + if self.info.module_def.get_http_handler_by_id(handler_id).is_none() { + return Err(HttpHandlerCallError::NoSuchHandler); + } + + let params = CallHttpHandlerParams { + timestamp: Timestamp::now(), + handler_id, + request, + }; + + self.call_pooled( + "http handler", + params, + async move |params, inst| inst.call_http_handler(params).await, + // TODO(v8-http-handlers): Do something useful here. + async move |_params, _inst| Err(HttpHandlerCallError::UnsupportedHostType), + ) + .await + .map_err(HttpHandlerCallError::from)? + } + pub(super) async fn call_scheduled_function( &self, params: ScheduledFunctionParams, diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index 332c9c89dd3..33d393f4532 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -22,9 +22,9 @@ use crate::host::module_host::{ use crate::host::scheduler::{CallScheduledFunctionResult, ScheduledFunctionParams}; use crate::host::wasm_common::instrumentation::CallTimes; use crate::host::wasm_common::module_host_actor::{ - AnonymousViewOp, DescribeError, ExecutionError, ExecutionResult, ExecutionStats, ExecutionTimings, InstanceCommon, - InstanceOp, ProcedureExecuteResult, ProcedureOp, ReducerExecuteResult, ReducerOp, ViewExecuteResult, ViewOp, - WasmInstance, + AnonymousViewOp, DescribeError, EnergyStats, ExecutionError, ExecutionResult, ExecutionStats, ExecutionTimings, + HttpHandlerExecuteResult, HttpHandlerOp, InstanceCommon, InstanceOp, ProcedureExecuteResult, ProcedureOp, + ReducerExecuteResult, ReducerOp, ViewExecuteResult, ViewOp, WasmInstance, }; use crate::host::wasm_common::{RowIters, TimingSpanSet}; use crate::host::{ModuleHost, ReducerCallError, ReducerCallResult, Scheduler}; @@ -1417,6 +1417,22 @@ impl WasmInstance for V8Instance<'_, '_, '_> { .take_procedure_tx_offset(); (result, tx_offset) } + + async fn call_http_handler( + &mut self, + _op: HttpHandlerOp, + _budget: FunctionBudget, + ) -> (HttpHandlerExecuteResult, Option) { + let result = ExecutionResult { + stats: ExecutionStats { + energy: EnergyStats::ZERO, + timings: ExecutionTimings::zero(), + memory_allocation: 0, + }, + call_result: Err(anyhow::anyhow!("HTTP handlers are not supported for JS modules")), + }; + (result, None) + } } fn common_call<'scope, R, O, F>( diff --git a/crates/core/src/host/wasm_common.rs b/crates/core/src/host/wasm_common.rs index a5c737d54d6..b9cd4e16b65 100644 --- a/crates/core/src/host/wasm_common.rs +++ b/crates/core/src/host/wasm_common.rs @@ -16,6 +16,8 @@ pub const CALL_REDUCER_DUNDER: &str = "__call_reducer__"; pub const CALL_PROCEDURE_DUNDER: &str = "__call_procedure__"; +pub const CALL_HTTP_HANDLER_DUNDER: &str = "__call_http_handler__"; + pub const CALL_VIEW_DUNDER: &str = "__call_view__"; pub const CALL_VIEW_ANON_DUNDER: &str = "__call_view_anon__"; diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index a711e0f18dc..e723f4b3791 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -8,9 +8,9 @@ use crate::host::host_controller::CallProcedureReturn; use crate::host::instance_env::{InstanceEnv, TxSlot}; use crate::host::module_common::{build_common_module_from_raw, ModuleCommon}; use crate::host::module_host::{ - call_identity_connected, init_database, CallProcedureParams, CallReducerParams, CallViewParams, - ClientConnectedError, DatabaseUpdate, EventStatus, ModuleEvent, ModuleFunctionCall, ModuleInfo, RefInstance, - ViewCallResult, ViewCommand, ViewCommandResult, ViewOutcome, + call_identity_connected, init_database, CallHttpHandlerParams, CallProcedureParams, CallReducerParams, + CallViewParams, ClientConnectedError, DatabaseUpdate, EventStatus, HttpHandlerCallError, ModuleEvent, + ModuleFunctionCall, ModuleInfo, RefInstance, ViewCallResult, ViewCommand, ViewCommandResult, ViewOutcome, }; use crate::host::scheduler::{CallScheduledFunctionResult, ScheduledFunctionParams}; use crate::host::{ @@ -44,8 +44,8 @@ use spacetimedb_lib::db::raw_def::v9::{Lifecycle, ViewResultHeader}; use spacetimedb_lib::de::DeserializeSeed; use spacetimedb_lib::identity::AuthCtx; use spacetimedb_lib::metrics::ExecutionMetrics; -use spacetimedb_lib::{bsatn, ConnectionId, Hash, ProductType, RawModuleDef, Timestamp}; -use spacetimedb_primitives::{ProcedureId, TableId, ViewFnPtr, ViewId}; +use spacetimedb_lib::{bsatn, http as st_http, ConnectionId, Hash, ProductType, RawModuleDef, Timestamp}; +use spacetimedb_primitives::{HttpHandlerId, ProcedureId, TableId, ViewFnPtr, ViewId}; use spacetimedb_sats::algebraic_type::fmt::fmt_algebraic_type; use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, Deserialize, ProductValue, Typespace, WithTypespace}; use spacetimedb_schema::auto_migrate::{MigratePlan, MigrationPolicy, MigrationPolicyError}; @@ -97,6 +97,12 @@ pub trait WasmInstance { op: ProcedureOp, budget: FunctionBudget, ) -> impl Future)>; + + fn call_http_handler( + &mut self, + op: HttpHandlerOp, + budget: FunctionBudget, + ) -> impl Future)>; } pub struct EnergyStats { @@ -312,6 +318,8 @@ pub type ViewExecuteResult = ExecutionResult; pub type ProcedureExecuteResult = ExecutionResult; +pub type HttpHandlerExecuteResult = ExecutionResult; + pub struct WasmModuleHostActor { module: T::InstancePre, common: ModuleCommon, @@ -527,6 +535,15 @@ impl WasmModuleInstance { res } + pub async fn call_http_handler( + &mut self, + params: CallHttpHandlerParams, + ) -> Result { + let (res, trapped) = self.common.call_http_handler(params, &mut self.instance).await; + self.trapped = trapped; + res + } + pub(in crate::host) async fn call_scheduled_function( &mut self, params: ScheduledFunctionParams, @@ -786,6 +803,85 @@ impl InstanceCommon { (CallProcedureReturn { result, tx_offset }, trapped) } + pub(crate) async fn call_http_handler( + &mut self, + params: CallHttpHandlerParams, + inst: &mut I, + ) -> (Result, bool) { + let CallHttpHandlerParams { + timestamp, + handler_id, + request, + } = params; + + let Some(handler_def) = self.info.module_def.get_http_handler_by_id(handler_id) else { + return (Err(HttpHandlerCallError::NoSuchHandler), false); + }; + let handler_name = &handler_def.name; + + let request_bytes = match bsatn::to_vec(&request) { + Ok(bytes) => bytes.into(), + Err(err) => { + return ( + Err(HttpHandlerCallError::InternalError(format!( + "failed to serialize request: {err}" + ))), + false, + ) + } + }; + + let op = HttpHandlerOp { + id: handler_id, + name: handler_name.clone(), + timestamp, + request_bytes, + }; + + let energy_fingerprint = FunctionFingerprint { + module_hash: self.info.module_hash, + module_identity: self.info.owner_identity, + caller_identity: self.info.owner_identity, + function_name: handler_name, + }; + + let budget = self.energy_monitor.reducer_budget(&energy_fingerprint); + + let (result, _tx_offset) = inst.call_http_handler(op, budget).await; + + let HttpHandlerExecuteResult { + stats: + ExecutionStats { + memory_allocation, + // TODO(http-handler-energy): Do something with timing and energy. + .. + }, + call_result, + } = result; + + if self.allocated_memory != memory_allocation { + self.metric_wasm_memory_bytes.set(memory_allocation as i64); + self.allocated_memory = memory_allocation; + } + + let trapped = call_result.is_err(); + + let result = match call_result { + Err(err) => { + inst.log_traceback("http handler", handler_name, &err); + WORKER_METRICS + .wasm_instance_errors + .with_label_values(&self.info.database_identity, &self.info.module_hash, handler_name) + .inc(); + Err(HttpHandlerCallError::InternalError(format!("{err}"))) + } + Ok(return_val) => bsatn::from_slice::(&return_val[..]) + .map_err(|err| HttpHandlerCallError::InternalError(format!("{err}"))), + }; + + (result, trapped) + } + /// Execute a reducer. /// /// If `Some` [`MutTxId`] is supplied, the reducer is called within the @@ -1738,6 +1834,27 @@ impl InstanceOp for ProcedureOp { } } +/// Describes an HTTP handler call in a cheaply shareable way. +#[derive(Clone, Debug)] +pub struct HttpHandlerOp { + pub id: HttpHandlerId, + pub name: Identifier, + pub timestamp: Timestamp, + pub request_bytes: Bytes, +} + +impl InstanceOp for HttpHandlerOp { + fn name(&self) -> &Identifier { + &self.name + } + fn timestamp(&self) -> Timestamp { + self.timestamp + } + fn call_type(&self) -> FuncCallType { + FuncCallType::Procedure + } +} + #[cfg(test)] mod tests { use super::collect_subscribed_view_calls; diff --git a/crates/core/src/host/wasmtime/wasmtime_module.rs b/crates/core/src/host/wasmtime/wasmtime_module.rs index 48ac0fe80e2..2e517e61848 100644 --- a/crates/core/src/host/wasmtime/wasmtime_module.rs +++ b/crates/core/src/host/wasmtime/wasmtime_module.rs @@ -266,6 +266,7 @@ impl module_host_actor::WasmInstancePre for WasmtimeModule { let call_procedure = get_call_procedure(&mut store, &instance); let call_view = get_call_view(&mut store, &instance); let call_view_anon = get_call_view_anon(&mut store, &instance); + let call_http_handler = get_call_http_handler(&mut store, &instance); store .data_mut() .set_call_view_exports(call_view.clone(), call_view_anon.clone()); @@ -277,6 +278,7 @@ impl module_host_actor::WasmInstancePre for WasmtimeModule { call_procedure, call_view, call_view_anon, + call_http_handler, }) } } @@ -337,6 +339,20 @@ fn get_call_view_anon(store: &mut Store, instance: &Instance) - ) } +/// Look up the `instance`'s export named by [`CALL_HTTP_HANDLER_DUNDER`]. +/// +/// Similar to [`get_call_procedure`], but for HTTP handlers. +fn get_call_http_handler(store: &mut Store, instance: &Instance) -> Option { + let export = instance.get_export(store.as_context_mut(), CALL_HTTP_HANDLER_DUNDER)?; + Some( + export + .into_func() + .unwrap_or_else(|| panic!("{CALL_HTTP_HANDLER_DUNDER} export is not a function")) + .typed(store) + .unwrap_or_else(|err| panic!("{CALL_HTTP_HANDLER_DUNDER} export is a function with incorrect type: {err}")), + ) +} + // `__call_procedure__` takes the same arguments as `__call_reducer__`. type CallProcedureType = CallReducerType; @@ -401,6 +417,21 @@ pub(super) type CallViewAnonType = TypedFunc< i32, >; +/// The function signature of `__call_http_handler__` +pub(super) type CallHttpHandlerType = TypedFunc< + ( + // HttpHandlerId + u32, + // timestamp + u64, + // byte source id for request + u32, + // byte sink id for response + u32, + ), + i32, +>; + pub struct WasmtimeInstance { store: Store, instance: Instance, @@ -408,6 +439,7 @@ pub struct WasmtimeInstance { call_procedure: Option, call_view: Option, call_view_anon: Option, + call_http_handler: Option, } impl module_host_actor::WasmInstance for WasmtimeInstance { @@ -634,6 +666,64 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { (res, tx_offset) } + + #[tracing::instrument(level = "trace", skip_all)] + async fn call_http_handler( + &mut self, + op: module_host_actor::HttpHandlerOp, + budget: FunctionBudget, + ) -> (module_host_actor::HttpHandlerExecuteResult, Option) { + let store = &mut self.store; + prepare_store_for_call(store, budget); + + let call_type = op.call_type(); + let (request_source, result_sink) = + store + .data_mut() + .start_funcall(op.name.clone(), op.request_bytes, op.timestamp, call_type); + + let Some(call_http_handler) = self.call_http_handler.as_ref() else { + let res = module_host_actor::HttpHandlerExecuteResult { + stats: zero_execution_stats(store), + call_result: Err(anyhow::anyhow!( + "Module defines http handler {} but does not export `{}`", + op.name, + CALL_HTTP_HANDLER_DUNDER, + )), + }; + return (res, None); + }; + + let call_result = call_http_handler + .call_async( + &mut *store, + ( + op.id.0, + op.timestamp.to_micros_since_unix_epoch() as u64, + request_source.0, + result_sink, + ), + ) + .await; + + store.data_mut().terminate_dangling_anon_tx(); + + let (stats, result_bytes) = finish_opcall(store, budget); + + let call_result = call_result.and_then(|code| { + (code == 0).then_some(result_bytes.into()).ok_or_else(|| { + anyhow::anyhow!( + "{CALL_HTTP_HANDLER_DUNDER} returned unexpected code {code}. HTTP handlers should return code 0 or trap." + ) + }) + }); + + let res = module_host_actor::HttpHandlerExecuteResult { stats, call_result }; + + let tx_offset = store.data_mut().take_procedure_tx_offset(); + + (res, tx_offset) + } } fn set_store_fuel(store: &mut impl AsContextMut, fuel: WasmtimeFuel) { diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index 9eadb07888a..67f10d22485 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -39,6 +39,7 @@ spacetimedb-metrics = { workspace = true, optional = true } anyhow.workspace = true bitflags.workspace = true +bytes.workspace = true chrono.workspace = true derive_more.workspace = true enum-as-inner.workspace = true diff --git a/crates/lib/src/db/raw_def/v10.rs b/crates/lib/src/db/raw_def/v10.rs index a801ea286be..47a4281e86a 100644 --- a/crates/lib/src/db/raw_def/v10.rs +++ b/crates/lib/src/db/raw_def/v10.rs @@ -89,6 +89,36 @@ pub enum RawModuleDefV10Section { /// Names provided explicitly by the user that do not follow from the case conversion policy. ExplicitNames(ExplicitNames), + + /// HTTP handler function definitions. + HttpHandlers(Vec), + + /// HTTP route definitions. + HttpRoutes(Vec), +} + +#[derive(Debug, Clone, SpacetimeType)] +#[sats(crate = crate)] +#[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] +pub struct RawHttpHandlerDefV10 { + pub source_name: RawIdentifier, +} + +#[derive(Debug, Clone, SpacetimeType)] +#[sats(crate = crate)] +#[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] +pub struct RawHttpRouteDefV10 { + pub handler_function: RawIdentifier, + pub method: MethodOrAny, + pub path: RawIdentifier, +} + +#[derive(Debug, Clone, SpacetimeType, PartialEq, Eq, PartialOrd, Ord)] +#[sats(crate = crate)] +#[non_exhaustive] +pub enum MethodOrAny { + Any, + Method(crate::http::Method), } #[derive(Debug, Clone, Copy, Default, SpacetimeType)] @@ -608,6 +638,20 @@ impl RawModuleDefV10 { _ => None, }) } + + pub fn http_handlers(&self) -> Option<&Vec> { + self.sections.iter().find_map(|s| match s { + RawModuleDefV10Section::HttpHandlers(handlers) => Some(handlers), + _ => None, + }) + } + + pub fn http_routes(&self) -> Option<&Vec> { + self.sections.iter().find_map(|s| match s { + RawModuleDefV10Section::HttpRoutes(routes) => Some(routes), + _ => None, + }) + } } /// A builder for a [`RawModuleDefV10`]. @@ -805,6 +849,46 @@ impl RawModuleDefV10Builder { } } + /// Get mutable access to the HTTP handlers section, creating it if missing. + fn http_handlers_mut(&mut self) -> &mut Vec { + let idx = self + .module + .sections + .iter() + .position(|s| matches!(s, RawModuleDefV10Section::HttpHandlers(_))) + .unwrap_or_else(|| { + self.module + .sections + .push(RawModuleDefV10Section::HttpHandlers(Vec::new())); + self.module.sections.len() - 1 + }); + + match &mut self.module.sections[idx] { + RawModuleDefV10Section::HttpHandlers(handlers) => handlers, + _ => unreachable!("Just ensured HttpHandlers section exists"), + } + } + + /// Get mutable access to the HTTP routes section, creating it if missing. + fn http_routes_mut(&mut self) -> &mut Vec { + let idx = self + .module + .sections + .iter() + .position(|s| matches!(s, RawModuleDefV10Section::HttpRoutes(_))) + .unwrap_or_else(|| { + self.module + .sections + .push(RawModuleDefV10Section::HttpRoutes(Vec::new())); + self.module.sections.len() - 1 + }); + + match &mut self.module.sections[idx] { + RawModuleDefV10Section::HttpRoutes(routes) => routes, + _ => unreachable!("Just ensured HttpRoutes section exists"), + } + } + /// Create a table builder. /// /// Does not validate that the product_type_ref is valid; this is left to the module validation code. @@ -1050,6 +1134,27 @@ impl RawModuleDefV10Builder { .push(RawRowLevelSecurityDefV10 { sql: sql.into() }); } + /// Add an HTTP handler to the module. + pub fn add_http_handler(&mut self, source_name: impl Into) { + self.http_handlers_mut().push(RawHttpHandlerDefV10 { + source_name: source_name.into(), + }); + } + + /// Add an HTTP route to the module. + pub fn add_http_route( + &mut self, + handler_function: impl Into, + method: MethodOrAny, + path: impl Into, + ) { + self.http_routes_mut().push(RawHttpRouteDefV10 { + handler_function: handler_function.into(), + method, + path: path.into(), + }); + } + pub fn add_explicit_names(&mut self, names: ExplicitNames) { self.explicit_names_mut().merge(names); } diff --git a/crates/lib/src/http.rs b/crates/lib/src/http.rs index cba80c30007..b9ed6331ea1 100644 --- a/crates/lib/src/http.rs +++ b/crates/lib/src/http.rs @@ -18,6 +18,7 @@ //! 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 bytes::Bytes; use spacetimedb_sats::{time_duration::TimeDuration, SpacetimeType}; /// Represents an HTTP request which can be made from a procedure running in a SpacetimeDB database. @@ -45,7 +46,7 @@ impl Request { } /// Represents an HTTP method. -#[derive(Clone, SpacetimeType, PartialEq, Eq)] +#[derive(Clone, Debug, SpacetimeType, PartialEq, Eq, PartialOrd, Ord)] #[sats(crate = crate, name = "HttpMethod")] pub enum Method { Get, @@ -165,3 +166,19 @@ impl Response { self.headers.size_in_bytes() } } + +/// An HTTP request plus a body, used for host <-> module interchange in HTTP handlers. +#[derive(Clone, SpacetimeType)] +#[sats(crate = crate, name = "HttpRequestAndBody")] +pub struct RequestAndBody { + pub request: Request, + pub body: Bytes, +} + +/// An HTTP response plus a body, used for host <-> module interchange in HTTP handlers. +#[derive(Clone, SpacetimeType)] +#[sats(crate = crate, name = "HttpResponseAndBody")] +pub struct ResponseAndBody { + pub response: Response, + pub body: Bytes, +} diff --git a/crates/primitives/src/ids.rs b/crates/primitives/src/ids.rs index 942a3456669..e593b93f71c 100644 --- a/crates/primitives/src/ids.rs +++ b/crates/primitives/src/ids.rs @@ -138,6 +138,12 @@ system_id! { pub struct ProcedureId(pub u32); } +system_id! { + /// The index of an HTTP handler as defined in a module's HTTP handler list. + // This is never stored in a system table, but is useful to have defined here. + pub struct HttpHandlerId(pub u32); +} + system_id! { /// The index of a view as defined in a module's view lists. /// diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index 8a316f44c19..f23bacd701f 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -8,8 +8,8 @@ mod ids; pub use attr::{AttributeKind, ColumnAttribute, ConstraintKind, Constraints}; pub use col_list::{ColList, ColOrCols, ColSet}; pub use ids::{ - ArgId, ColId, ConstraintId, FunctionId, IndexId, ProcedureId, ReducerId, ScheduleId, SequenceId, TableId, - ViewFnPtr, ViewId, + ArgId, ColId, ConstraintId, FunctionId, HttpHandlerId, IndexId, ProcedureId, ReducerId, ScheduleId, SequenceId, + TableId, ViewFnPtr, ViewId, }; /// The minimum size of a chunk yielded by a wasm abi RowIter. diff --git a/crates/schema/Cargo.toml b/crates/schema/Cargo.toml index 313e8dad38d..52cceb0243d 100644 --- a/crates/schema/Cargo.toml +++ b/crates/schema/Cargo.toml @@ -17,6 +17,11 @@ spacetimedb-data-structures.workspace = true spacetimedb-memory-usage.workspace = true spacetimedb-sql-parser.workspace = true +# Used only for a call to `PathAndQuery::from_str(path_str).is_err()` +# in `src/def/validate/v10.rs#validate_http_route_path`. +# Possibly would be nice to eliminate this dependency. +http.workspace = true + anyhow.workspace = true derive_more.workspace = true indexmap.workspace = true diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index 89c201e3f85..baae44ed76f 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -32,9 +32,10 @@ use spacetimedb_data_structures::error_stream::{CollectAllErrors, CombineErrors, use spacetimedb_data_structures::map::{Equivalent, HashMap}; use spacetimedb_lib::db::raw_def; use spacetimedb_lib::db::raw_def::v10::{ - ExplicitNames, RawConstraintDefV10, RawIndexDefV10, RawLifeCycleReducerDefV10, RawModuleDefV10, - RawModuleDefV10Section, RawProcedureDefV10, RawReducerDefV10, RawRowLevelSecurityDefV10, RawScheduleDefV10, - RawScopedTypeNameV10, RawSequenceDefV10, RawTableDefV10, RawTypeDefV10, RawViewDefV10, + ExplicitNames, MethodOrAny, RawConstraintDefV10, RawHttpHandlerDefV10, RawHttpRouteDefV10, RawIndexDefV10, + RawLifeCycleReducerDefV10, RawModuleDefV10, RawModuleDefV10Section, RawProcedureDefV10, RawReducerDefV10, + RawRowLevelSecurityDefV10, RawScheduleDefV10, RawScopedTypeNameV10, RawSequenceDefV10, RawTableDefV10, + RawTypeDefV10, RawViewDefV10, }; use spacetimedb_lib::db::raw_def::v9::{ Lifecycle, RawColumnDefaultValueV9, RawConstraintDataV9, RawConstraintDefV9, RawIndexAlgorithm, RawIndexDefV9, @@ -43,7 +44,9 @@ use spacetimedb_lib::db::raw_def::v9::{ RawUniqueConstraintDataV9, RawViewDefV9, TableAccess, TableType, }; use spacetimedb_lib::{ProductType, RawModuleDef}; -use spacetimedb_primitives::{ColId, ColList, ColOrCols, ColSet, ProcedureId, ReducerId, TableId, ViewFnPtr}; +use spacetimedb_primitives::{ + ColId, ColList, ColOrCols, ColSet, HttpHandlerId, ProcedureId, ReducerId, TableId, ViewFnPtr, +}; use spacetimedb_sats::raw_identifier::RawIdentifier; use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, AlgebraicValue, Typespace}; @@ -115,6 +118,15 @@ pub struct ModuleDef { /// so that `__call_procedure__` receives stable integer IDs. procedures: IndexMap, + /// The HTTP handlers of the module definition. + /// + /// Uses [`IndexMap`] to preserve order so that `__call_http_handler__` + /// receives stable integer IDs. + http_handlers: IndexMap, + + /// The HTTP routes of the module definition. + http_routes: Vec, + /// The views of the module definition. /// /// Like `reducers`, this uses [`IndexMap`] to preserve order @@ -207,6 +219,61 @@ impl ModuleDef { self.procedures.values() } + /// The HTTP handlers of the module definition. + pub fn http_handlers(&self) -> impl Iterator { + self.http_handlers.values() + } + + /// The HTTP routes of the module definition. + pub fn http_routes(&self) -> &[HttpRouteDef] { + &self.http_routes + } + + /// Returns an iterator over all HTTP handler ids and definitions. + pub fn http_handler_ids_and_defs(&self) -> impl ExactSizeIterator { + self.http_handlers + .values() + .enumerate() + .map(|(idx, def)| (idx.into(), def)) + } + + pub fn http_handler_by_id(&self, id: HttpHandlerId) -> &HttpHandlerDef { + &self.http_handlers[id.0 as usize] + } + + pub fn get_http_handler_by_id(&self, id: HttpHandlerId) -> Option<&HttpHandlerDef> { + self.http_handlers.get_index(id.0 as usize).map(|(_, def)| def) + } + + pub fn http_handler_full>( + &self, + name: &K, + ) -> Option<(HttpHandlerId, &HttpHandlerDef)> { + let (idx, _key, def) = self.http_handlers.get_full(name)?; + Some((HttpHandlerId(idx as u32), def)) + } + + pub fn match_http_route( + &self, + method: &spacetimedb_lib::http::Method, + path: &str, + ) -> Option<(HttpHandlerId, &HttpHandlerDef, &HttpRouteDef)> { + // TODO(perf): Replace this linear scan with a trie or other indexed routing structure. + for route in &self.http_routes { + if route.path.as_ref() != path { + continue; + } + let method_matches = matches!(route.method, MethodOrAny::Any) + || matches!(route.method, MethodOrAny::Method(ref route_method) if route_method == method); + if !method_matches { + continue; + } + let (handler_id, handler_def) = self.http_handler_full(&route.handler_name)?; + return Some((handler_id, handler_def, route)); + } + None + } + /// The views of the module definition. pub fn views(&self) -> impl Iterator { self.views.values() @@ -436,6 +503,8 @@ impl From for RawModuleDefV9 { refmap: _, row_level_security_raw, procedures, + http_handlers: _, + http_routes: _, raw_module_def_version: _, } = val; @@ -492,6 +561,8 @@ impl From for RawModuleDefV10 { refmap: _, row_level_security_raw, procedures, + http_handlers, + http_routes, raw_module_def_version: _, } = val; @@ -574,6 +645,28 @@ impl From for RawModuleDefV10 { sections.push(RawModuleDefV10Section::Procedures(raw_procedures)); } + let raw_http_handlers: Vec = http_handlers + .into_values() + .map(|hd| RawHttpHandlerDefV10 { + source_name: hd.accessor_name.into(), + }) + .collect(); + if !raw_http_handlers.is_empty() { + sections.push(RawModuleDefV10Section::HttpHandlers(raw_http_handlers)); + } + + if !http_routes.is_empty() { + let raw_http_routes: Vec = http_routes + .into_iter() + .map(|route| RawHttpRouteDefV10 { + handler_function: route.handler_name.into(), + method: route.method, + path: RawIdentifier::new(route.path.as_ref()), + }) + .collect(); + sections.push(RawModuleDefV10Section::HttpRoutes(raw_http_routes)); + } + // Collect ExplicitNames for views: accessor_name → source_name, name → canonical_name. let raw_views: Vec = views .into_values() @@ -1742,6 +1835,24 @@ pub struct ProcedureDef { pub visibility: FunctionVisibility, } +#[derive(Debug, Clone, Eq, PartialEq)] +#[non_exhaustive] +pub struct HttpHandlerDef { + /// The canonical name of the handler. + pub name: Identifier, + + /// The handler name as defined in the module source. + pub accessor_name: Identifier, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +#[non_exhaustive] +pub struct HttpRouteDef { + pub handler_name: Identifier, + pub method: spacetimedb_lib::db::raw_def::v10::MethodOrAny, + pub path: Box, +} + impl From for RawProcedureDefV9 { fn from(val: ProcedureDef) -> Self { RawProcedureDefV9 { diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 7ebbaae06d4..691aebe6a99 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -1,3 +1,5 @@ +use http::uri::PathAndQuery; +use spacetimedb_data_structures::error_stream::ErrorStream; use spacetimedb_data_structures::map::HashMap; use spacetimedb_lib::bsatn::Deserializer; use spacetimedb_lib::db::raw_def::v10::*; @@ -13,6 +15,7 @@ use crate::def::*; use crate::error::ValidationError; use crate::type_for_generate::ProductTypeDef; use crate::{def::validate::Result, error::TypeLocation}; +use std::str::FromStr; // Utitility struct to look up canonical names for tables, functions, and indexes based on the // explicit names provided in the `RawModuleDefV10`. @@ -136,6 +139,18 @@ pub fn validate(def: RawModuleDefV10) -> Result { // Later on, in `check_function_names_are_unique`, we'll transform this into an `IndexMap`. .collect_all_errors::>(); + let http_handlers = def + .http_handlers() + .cloned() + .into_iter() + .flatten() + .map(|handler| { + validator + .validate_http_handler_def(handler) + .map(|handler_def| (handler_def.name.clone(), handler_def)) + }) + .collect_all_errors::>(); + let views = def .views() .cloned() @@ -221,6 +236,19 @@ pub fn validate(def: RawModuleDefV10) -> Result { .collect_all_errors::>() }) .unwrap_or_else(|| Ok(Vec::new())); + + let http_handlers_and_routes = http_handlers.and_then(|handlers| { + let handlers = check_http_handler_names_are_unique(handlers)?; + let routes = def + .http_routes() + .cloned() + .into_iter() + .flatten() + .map(|route| validator.validate_http_route_def(route, &handlers)) + .collect_all_errors::>()?; + validate_http_routes_are_unique(&routes)?; + Ok((handlers, routes)) + }); // Combine all validation results let tables_types_reducers_procedures_views = ( tables, @@ -230,10 +258,11 @@ pub fn validate(def: RawModuleDefV10) -> Result { views, schedules, lifecycle_validations, + http_handlers_and_routes, ) .combine_errors() .and_then( - |(mut tables, types, reducers, procedures, views, schedules, lifecycles)| { + |(mut tables, types, reducers, procedures, views, schedules, lifecycles, http_handlers_and_routes)| { let (mut reducers, mut procedures, mut views) = check_function_names_are_unique(reducers, procedures, views)?; // Attach lifecycles to their respective reducers @@ -246,7 +275,7 @@ pub fn validate(def: RawModuleDefV10) -> Result { change_scheduled_functions_and_lifetimes_visibility(&tables, &mut reducers, &mut procedures)?; assign_query_view_primary_keys(&tables, &mut views); - Ok((tables, types, reducers, procedures, views)) + Ok((tables, types, reducers, procedures, views, http_handlers_and_routes)) }, ); let CoreValidator { @@ -263,7 +292,7 @@ pub fn validate(def: RawModuleDefV10) -> Result { .map(|rls| (rls.sql.clone(), rls.to_owned())) .collect(); - let (tables, types, reducers, procedures, views) = + let (tables, types, reducers, procedures, views, (http_handlers, http_routes)) = (tables_types_reducers_procedures_views).map_err(|errors| errors.sort_deduplicate())?; let typespace_for_generate = typespace_for_generate.finish(); @@ -280,6 +309,8 @@ pub fn validate(def: RawModuleDefV10) -> Result { row_level_security_raw, lifecycle_reducers, procedures, + http_handlers, + http_routes, raw_module_def_version: RawModuleDefVersion::V10, }) } @@ -328,6 +359,55 @@ fn change_scheduled_functions_and_lifetimes_visibility( Ok(()) } +fn validate_http_route_path(path: &RawIdentifier) -> Result<()> { + let path_str = path.as_ref(); + // TODO: detect more suspicious characters. https://stackoverflow.com/a/695467 seems like a reasonable set. + if !path_str.starts_with('/') || path_str.contains('?') || path_str.contains('#') { + return Err(ValidationError::InvalidHttpRoutePath { path: path.clone() }.into()); + } + if PathAndQuery::from_str(path_str).is_err() { + return Err(ValidationError::InvalidHttpRoutePath { path: path.clone() }.into()); + } + Ok(()) +} + +fn routes_overlap(a: &HttpRouteDef, b: &HttpRouteDef) -> bool { + if a.path != b.path { + return false; + } + matches!(a.method, MethodOrAny::Any) || matches!(b.method, MethodOrAny::Any) || a.method == b.method +} + +fn validate_http_routes_are_unique(routes: &[HttpRouteDef]) -> Result<()> { + let mut errors = Vec::new(); + for (idx, route) in routes.iter().enumerate() { + if routes.iter().take(idx).any(|existing| routes_overlap(existing, route)) { + errors.push(ValidationError::DuplicateHttpRoute { + path: RawIdentifier::new(route.path.as_ref()), + method: route.method.clone(), + }); + } + } + ErrorStream::add_extra_errors(Ok(()), errors) +} + +fn check_http_handler_names_are_unique( + handlers: Vec<(Identifier, HttpHandlerDef)>, +) -> Result> { + let mut errors = vec![]; + let mut handlers_map = IndexMap::with_capacity(handlers.len()); + + for (name, def) in handlers { + if handlers_map.contains_key(&name) { + errors.push(ValidationError::DuplicateHttpHandlerName { name }); + } else { + handlers_map.insert(name, def); + } + } + + ErrorStream::add_extra_errors(Ok(handlers_map), errors) +} + struct ModuleValidatorV10<'a> { core: CoreValidator<'a>, } @@ -669,6 +749,46 @@ impl<'a> ModuleValidatorV10<'a> { }) } + fn validate_http_handler_def(&mut self, handler_def: RawHttpHandlerDefV10) -> Result { + let RawHttpHandlerDefV10 { source_name, .. } = handler_def; + let accessor_name = identifier(source_name.clone()); + let name_result = self.core.resolve_function_ident(source_name); + let (name_result, accessor_name) = (name_result, accessor_name).combine_errors()?; + Ok(HttpHandlerDef { + name: name_result, + accessor_name, + }) + } + + fn validate_http_route_def( + &mut self, + route_def: RawHttpRouteDefV10, + handlers: &IndexMap, + ) -> Result { + let RawHttpRouteDefV10 { + handler_function, + method, + path, + .. + } = route_def; + + validate_http_route_path(&path)?; + + let handler_name = self.core.resolve_function_ident(handler_function.clone())?; + if !handlers.contains_key(&handler_name) { + return Err(ValidationError::MissingHttpHandler { + handler: handler_function, + } + .into()); + } + + Ok(HttpRouteDef { + handler_name, + method, + path: path.as_ref().into(), + }) + } + fn validate_view_def(&mut self, view_def: RawViewDefV10, typespace_with_accessor: &Typespace) -> Result { let RawViewDefV10 { source_name: accessor_name, @@ -877,9 +997,10 @@ mod tests { use itertools::Itertools; use spacetimedb_data_structures::expect_error_matching; - use spacetimedb_lib::db::raw_def::v10::{CaseConversionPolicy, RawModuleDefV10Builder}; + use spacetimedb_lib::db::raw_def::v10::{CaseConversionPolicy, MethodOrAny, RawModuleDefV10Builder}; use spacetimedb_lib::db::raw_def::v9::{btree, direct, hash}; use spacetimedb_lib::db::raw_def::*; + use spacetimedb_lib::http::Method as HttpMethod; use spacetimedb_lib::ScheduleAt; use spacetimedb_primitives::{ColId, ColList, ColSet}; use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, AlgebraicValue, ProductType, SumValue}; @@ -1645,6 +1766,90 @@ mod tests { }); } + #[test] + fn duplicate_http_handler_names() { + let mut builder = RawModuleDefV10Builder::new(); + + builder.add_http_handler("handle"); + builder.add_http_handler("handle"); + + let result: Result = builder.finish().try_into(); + + expect_error_matching!(result, ValidationError::DuplicateHttpHandlerName { name } => { + name == &expect_identifier("handle") + }); + } + + #[test] + fn http_routes_same_path_and_method() { + let mut builder = RawModuleDefV10Builder::new(); + + builder.add_http_handler("handle"); + builder.add_http_route("handle", MethodOrAny::Method(HttpMethod::Get), "/hook"); + builder.add_http_route("handle", MethodOrAny::Method(HttpMethod::Get), "/hook"); + + let result: Result = builder.finish().try_into(); + + expect_error_matching!(result, ValidationError::DuplicateHttpRoute { path, method } => { + path.as_ref() == "/hook" && *method == MethodOrAny::Method(HttpMethod::Get) + }); + } + + #[test] + fn http_routes_overlap_with_any() { + let mut builder = RawModuleDefV10Builder::new(); + + builder.add_http_handler("handle"); + builder.add_http_route("handle", MethodOrAny::Any, "/hook"); + builder.add_http_route("handle", MethodOrAny::Method(HttpMethod::Get), "/hook"); + + let result: Result = builder.finish().try_into(); + + expect_error_matching!(result, ValidationError::DuplicateHttpRoute { path, method } => { + path.as_ref() == "/hook" && *method == MethodOrAny::Method(HttpMethod::Get) + }); + } + + #[test] + fn http_routes_invalid_paths() { + let mut builder = RawModuleDefV10Builder::new(); + + builder.add_http_handler("handle"); + builder.add_http_route("handle", MethodOrAny::Any, "no-slash"); + // TODO(testing): Extend this test with more invalid paths. + + let result: Result = builder.finish().try_into(); + + expect_error_matching!(result, ValidationError::InvalidHttpRoutePath { path } => { + path.as_ref() == "no-slash" + }); + } + + #[test] + fn http_routes_missing_handler() { + let mut builder = RawModuleDefV10Builder::new(); + + builder.add_http_route("missing", MethodOrAny::Any, "/hook"); + + let result: Result = builder.finish().try_into(); + + expect_error_matching!(result, ValidationError::MissingHttpHandler { handler } => { + handler.as_ref() == "missing" + }); + } + + #[test] + fn reducer_and_http_handler_can_share_name() { + let mut builder = RawModuleDefV10Builder::new(); + + builder.add_reducer("handle", ProductType::unit()); + builder.add_http_handler("handle"); + + let result: Result = builder.finish().try_into(); + + assert!(result.is_ok()); + } + fn make_case_conversion_builder() -> (RawModuleDefV10Builder, AlgebraicTypeRef) { let mut builder = RawModuleDefV10Builder::new(); diff --git a/crates/schema/src/def/validate/v9.rs b/crates/schema/src/def/validate/v9.rs index d040435afe5..115c419cfed 100644 --- a/crates/schema/src/def/validate/v9.rs +++ b/crates/schema/src/def/validate/v9.rs @@ -165,6 +165,8 @@ pub fn validate(def: RawModuleDefV9) -> Result { row_level_security_raw, lifecycle_reducers, procedures, + http_handlers: IndexMap::new(), + http_routes: Vec::new(), raw_module_def_version: RawModuleDefVersion::V9OrEarlier, }) } diff --git a/crates/schema/src/error.rs b/crates/schema/src/error.rs index 06f284998b5..5db1c0c6fde 100644 --- a/crates/schema/src/error.rs +++ b/crates/schema/src/error.rs @@ -1,4 +1,5 @@ use spacetimedb_data_structures::error_stream::ErrorStream; +use spacetimedb_lib::db::raw_def::v10::MethodOrAny; use spacetimedb_lib::db::raw_def::v9::{Lifecycle, RawScopedTypeNameV9}; use spacetimedb_lib::{ProductType, SumType}; use spacetimedb_primitives::{ColId, ColList, ColSet}; @@ -135,6 +136,14 @@ pub enum ValidationError { TableNotFound { table: RawIdentifier }, #[error("Name {name} is used for multiple reducers, procedures and/or views")] DuplicateFunctionName { name: Identifier }, + #[error("HTTP handler name {name} is used for multiple HTTP handlers")] + DuplicateHttpHandlerName { name: Identifier }, + #[error("HTTP route duplicates method {method:?} for path {path}")] + DuplicateHttpRoute { path: RawIdentifier, method: MethodOrAny }, + #[error("HTTP route path `{path}` is invalid")] + InvalidHttpRoutePath { path: RawIdentifier }, + #[error("HTTP route refers to unknown HTTP handler `{handler}`")] + MissingHttpHandler { handler: RawIdentifier }, #[error("lifecycle event {lifecycle:?} without reducer")] LifecycleWithoutReducer { lifecycle: Lifecycle }, #[error("lifecycle event {lifecycle:?} assigned multiple reducers")] From 8be80a2622da324f10e517a9ba9a5f36d944a015 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Thu, 9 Apr 2026 14:43:13 -0400 Subject: [PATCH 02/47] Add a smoketest Added a smoketest that publishes a module with some routes, then makes requests against those routes and makes some simple assertions about the responses. This revealed a bug introduced by the previous commit in the `/v1/database PUT` route, which was incorrectly not getting the `anon_auth_middleware` applied. --- Cargo.lock | 1 + crates/client-api/src/routes/database.rs | 28 +-- crates/smoketests/Cargo.toml | 1 + .../tests/smoketests/http_routes.rs | 164 ++++++++++++++++++ crates/smoketests/tests/smoketests/mod.rs | 1 + 5 files changed, 185 insertions(+), 10 deletions(-) create mode 100644 crates/smoketests/tests/smoketests/http_routes.rs diff --git a/Cargo.lock b/Cargo.lock index b2110511145..87bd0c79125 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8578,6 +8578,7 @@ dependencies = [ "cargo_metadata", "predicates", "regex", + "reqwest 0.12.24", "serde_json", "spacetimedb-core", "spacetimedb-guard", diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 83b9e87320d..ff5854b0f79 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -266,7 +266,7 @@ async fn handle_http_route_impl( let module_def = &module.info().module_def; let Some((handler_id, _handler_def, _route_def)) = module_def.match_http_route(&st_method, &handler_path) else { - return Ok(StatusCode::NOT_FOUND.into_response()); + return Ok((StatusCode::NOT_FOUND, NO_SUCH_ROUTE).into_response()); }; let body = body.collect().await.map_err(log_and_500)?.to_bytes(); @@ -1399,7 +1399,15 @@ where .route("/pre_publish", self.pre_publish) .route("/reset", self.db_reset); - let authed_router = axum::Router::new() + let authed_root_router = axum::Router::new().route( + "/", + self.root_post.layer(axum::middleware::from_fn_with_state( + ctx.clone(), + anon_auth_middleware::, + )), + ); + + let authed_named_router = axum::Router::new() .nest("/:name_or_identity", db_router) .route_layer(axum::middleware::from_fn_with_state(ctx, anon_auth_middleware::)); @@ -1413,8 +1421,8 @@ where .route("/:name_or_identity/route/*path", any(handle_http_route::)); axum::Router::new() - .route("/", self.root_post) - .merge(authed_router) + .merge(authed_root_router) + .merge(authed_named_router) .merge(http_route_router) } } @@ -1658,21 +1666,21 @@ mod tests { } impl Authorization for DummyState { - fn authorize_action( + async fn authorize_action( &self, _subject: Identity, _database: Identity, _action: Action, - ) -> impl std::future::Future> + Send { - async { Err(Unauthorized::InternalError(anyhow::anyhow!("unused"))) } + ) -> Result<(), Unauthorized> { + Err(Unauthorized::InternalError(anyhow::anyhow!("unused"))) } - fn authorize_sql( + async fn authorize_sql( &self, _subject: Identity, _database: Identity, - ) -> impl std::future::Future> + Send { - async { Err(Unauthorized::InternalError(anyhow::anyhow!("unused"))) } + ) -> Result { + Err(Unauthorized::InternalError(anyhow::anyhow!("unused"))) } } diff --git a/crates/smoketests/Cargo.toml b/crates/smoketests/Cargo.toml index bfa718e7daa..6e91d5c2c17 100644 --- a/crates/smoketests/Cargo.toml +++ b/crates/smoketests/Cargo.toml @@ -21,6 +21,7 @@ assert_cmd = "2" predicates = "3" tokio.workspace = true tokio-postgres.workspace = true +reqwest = { workspace = true, features = ["blocking"] } [lints] workspace = true diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs new file mode 100644 index 00000000000..93e11117c98 --- /dev/null +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -0,0 +1,164 @@ +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE: &str = r#" +use spacetimedb::http::{Body, Request, Response, Router}; +use spacetimedb::HandlerContext; +use spacetimedb::Table; + +#[spacetimedb::table(accessor = entries, public)] +pub struct Entry { + id: u64, + value: String, +} + +#[spacetimedb::http::handler] +fn get_simple(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::new(Body::from_bytes("ok")) +} + +#[spacetimedb::http::handler] +fn post_insert(ctx: &mut HandlerContext, _req: Request) -> Response { + ctx.with_tx(|tx| { + let id = tx.db.entries().iter().count() as u64; + tx.db.entries().insert(Entry { + id, + value: "posted".to_string(), + }); + }); + Response::new(Body::from_bytes("inserted")) +} + +#[spacetimedb::http::handler] +fn get_count(ctx: &mut HandlerContext, _req: Request) -> Response { + let count = ctx.with_tx(|tx| tx.db.entries().iter().count()); + Response::new(Body::from_bytes(count.to_string())) +} + +#[spacetimedb::http::handler] +fn any_handler(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::new(Body::from_bytes("any")) +} + +#[spacetimedb::http::handler] +fn header_echo(_ctx: &mut HandlerContext, req: Request) -> Response { + let value = req + .headers() + .get("x-echo") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + Response::new(Body::from_bytes(value.to_string())) +} + +#[spacetimedb::http::handler] +fn set_response_header(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::builder() + .header("x-response", "set") + .body(Body::from_bytes("header-set")) + .expect("response builder should not fail") +} + +#[spacetimedb::http::handler] +fn body_handler(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::new(Body::from_bytes("non-empty")) +} + +#[spacetimedb::http::handler] +fn teapot(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::builder() + .status(418) + .body(Body::from_bytes("teapot")) + .expect("response builder should not fail") +} + +#[spacetimedb::http::router] +fn router() -> Router { + Router::new() + .get("/get", get_simple) + .post("/post", post_insert) + .get("/count", get_count) + .any("/any", any_handler) + .get("/header", header_echo) + .get("/set-header", set_response_header) + .get("/body", body_handler) + .get("/teapot", teapot) +} +"#; + +const NO_SUCH_ROUTE_BODY: &str = "Database has not registered a handler for this route"; + +#[test] +fn http_routes_end_to_end() { + let test = Smoketest::builder().module_code(MODULE_CODE).build(); + let identity = test.database_identity.as_ref().expect("database identity missing"); + + let base = format!("{}/v1/database/{}/route", test.server_url, identity); + let client = reqwest::blocking::Client::new(); + + let resp = client.get(format!("{base}/get")).send().expect("get failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("get body"), "ok"); + + let resp = client + .post(format!("{base}/post")) + .body("payload") + .send() + .expect("post failed"); + assert!(resp.status().is_success()); + + let resp = client.get(format!("{base}/count")).send().expect("count failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("count body"), "1"); + + let resp = client.put(format!("{base}/any")).send().expect("any failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("any body"), "any"); + + let resp = client + .get(format!("{base}/header")) + .header("x-echo", "hello") + .send() + .expect("header echo failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("header body"), "hello"); + + let resp = client + .get(format!("{base}/set-header")) + .send() + .expect("set-header failed"); + assert!(resp.status().is_success()); + assert_eq!( + resp.headers().get("x-response").and_then(|value| value.to_str().ok()), + Some("set") + ); + + let resp = client.get(format!("{base}/body")).send().expect("body failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("body text"), "non-empty"); + + let resp = client.get(format!("{base}/teapot")).send().expect("teapot failed"); + assert_eq!(resp.status().as_u16(), 418); + + let resp = client + .get(format!("{base}/missing")) + .send() + .expect("missing route failed"); + assert_eq!(resp.status().as_u16(), 404); + assert_eq!(resp.text().expect("missing route body"), NO_SUCH_ROUTE_BODY); + + let resp = client + .get(format!( + "{}/v1/database/{}/schema?version=10", + test.server_url, identity + )) + .header("authorization", "Bearer not-a-jwt") + .send() + .expect("schema request failed"); + assert!(resp.status().is_client_error()); + + let resp = client + .get(format!("{base}/get")) + .header("authorization", "Bearer not-a-jwt") + .send() + .expect("route request failed"); + assert!(resp.status().is_success()); +} diff --git a/crates/smoketests/tests/smoketests/mod.rs b/crates/smoketests/tests/smoketests/mod.rs index f5053652dd3..8dc2561c505 100644 --- a/crates/smoketests/tests/smoketests/mod.rs +++ b/crates/smoketests/tests/smoketests/mod.rs @@ -19,6 +19,7 @@ mod domains; mod fail_initial_publish; mod filtering; mod http_egress; +mod http_routes; mod logs_level_filter; mod module_nested_op; mod modules; From ad560f15a4d00d229f4051f7c20dbd88f0f9bc2d Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Thu, 9 Apr 2026 15:02:57 -0400 Subject: [PATCH 03/47] fmt --- crates/client-api/src/routes/database.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index ff5854b0f79..324bb28b4b6 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -1675,11 +1675,7 @@ mod tests { Err(Unauthorized::InternalError(anyhow::anyhow!("unused"))) } - async fn authorize_sql( - &self, - _subject: Identity, - _database: Identity, - ) -> Result { + async fn authorize_sql(&self, _subject: Identity, _database: Identity) -> Result { Err(Unauthorized::InternalError(anyhow::anyhow!("unused"))) } } From 690bf66b73b8311c2110b9d8f11b17f14042a648 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 10 Apr 2026 11:36:46 -0400 Subject: [PATCH 04/47] Reorganize imports to correct feature gates --- crates/bindings/src/rt.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index 2f0c6759a8f..f1cda7f62ee 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -2,9 +2,7 @@ use crate::query_builder::{FromWhere, HasCols, LeftSemiJoin, RawQuery, RightSemiJoin, Table as QbTable}; use crate::table::IndexAlgo; -use crate::{ - http, sys, AnonymousViewContext, IterBuf, ReducerContext, ReducerResult, SpacetimeType, Table, ViewContext, -}; +use crate::{sys, AnonymousViewContext, IterBuf, ReducerContext, ReducerResult, SpacetimeType, Table, ViewContext}; use spacetimedb_lib::bsatn::EncodeError; use spacetimedb_lib::db::raw_def::v10::{ CaseConversionPolicy, ExplicitNames as RawExplicitNames, RawModuleDefV10Builder, @@ -24,7 +22,7 @@ use std::sync::{Mutex, OnceLock}; pub use sys::raw::{BytesSink, BytesSource}; #[cfg(feature = "unstable")] -use crate::{HandlerContext, ProcedureContext, ProcedureResult}; +use crate::{http, HandlerContext, ProcedureContext, ProcedureResult}; pub trait IntoVec { fn into_vec(self) -> Vec; From 7ac412a8a3ae3071f853c704eef0c8f433d91293 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 10 Apr 2026 12:17:32 -0400 Subject: [PATCH 05/47] Update bindings deps snapshot with `bytes` --- .../snapshots/deps__spacetimedb_bindings_dependencies.snap | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap b/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap index 9354026f0ba..9f881f3ad7a 100644 --- a/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap +++ b/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap @@ -1,5 +1,6 @@ --- source: crates/bindings/tests/deps.rs +assertion_line: 16 expression: "cargo tree -p spacetimedb -e no-dev --color never --target wasm32-unknown-unknown -f {lib}" --- total crates: 73 @@ -77,6 +78,7 @@ spacetimedb │ │ └── cc │ │ ├── find_msvc_tools │ │ └── shlex +│ ├── bytes │ ├── chrono │ │ └── num_traits │ │ [build-dependencies] From 77057cce76354951e78d2a85e94b71619a6fe44c Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 10 Apr 2026 12:18:59 -0400 Subject: [PATCH 06/47] Update smoketest modules Cargo.lock for `bytes` dependency --- crates/smoketests/modules/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/smoketests/modules/Cargo.lock b/crates/smoketests/modules/Cargo.lock index f355da02f96..223b22b827c 100644 --- a/crates/smoketests/modules/Cargo.lock +++ b/crates/smoketests/modules/Cargo.lock @@ -992,6 +992,7 @@ dependencies = [ "anyhow", "bitflags", "blake3", + "bytes", "chrono", "derive_more", "enum-as-inner", From 977c1a0ef624ebb779942091808134fa7f13cabf Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 10 Apr 2026 12:57:09 -0400 Subject: [PATCH 07/47] Add additional smoketest with example from PR description --- .../tests/smoketests/http_routes.rs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 93e11117c98..9f800351531 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -84,6 +84,49 @@ fn router() -> Router { } "#; +const EXAMPLE_MODULE_CODE: &str = r#" +use std::str::FromStr; + +use spacetimedb::http::{Body, Request, Response, Router}; +use spacetimedb::{HandlerContext, Table}; + +#[spacetimedb::table(accessor = data)] +struct Data { + #[primary_key] + #[auto_inc] + id: u64, + body: Vec, +} + +#[spacetimedb::http::handler] +fn insert(ctx: &mut HandlerContext, request: Request) -> Response { + let body: Vec = request.into_body().into_bytes().into(); + let id = ctx.with_tx(|tx| tx.db.data().insert(Data { id: 0, body: body.clone() }).id); + Response::new(Body::from_bytes(format!("{id}"))) +} + +#[spacetimedb::http::handler] +fn retrieve(ctx: &mut HandlerContext, request: Request) -> Response { + let id = request + .uri() + .query() + .and_then(|query| query.strip_prefix("id=")) + .and_then(|id| u64::from_str(id).ok()) + .unwrap(); + let body = ctx.with_tx(|tx| tx.db.data().id().find(id).map(|data| data.body)); + if let Some(body) = body { + Response::new(Body::from_bytes(body)) + } else { + Response::builder().status(404).body(Body::empty()).unwrap() + } +} + +#[spacetimedb::http::router] +fn router() -> Router { + Router::new().post("/insert", insert).get("/retrieve", retrieve) +} +"#; + const NO_SUCH_ROUTE_BODY: &str = "Database has not registered a handler for this route"; #[test] @@ -162,3 +205,40 @@ fn http_routes_end_to_end() { .expect("route request failed"); assert!(resp.status().is_success()); } + +#[test] +fn http_routes_pr_example_round_trip() { + let test = Smoketest::builder().module_code(EXAMPLE_MODULE_CODE).build(); + let identity = test.database_identity.as_ref().expect("database identity missing"); + + let base = format!("{}/v1/database/{}/route", test.server_url, identity); + let client = reqwest::blocking::Client::new(); + let payload = b"hello from the PR example".to_vec(); + + let resp = client + .post(format!("{base}/insert")) + .body(payload.clone()) + .send() + .expect("insert failed"); + assert!(resp.status().is_success()); + let inserted_id = resp.text().expect("insert id body"); + + let resp = client + .get(format!("{base}/retrieve?id={inserted_id}")) + .send() + .expect("retrieve existing failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.bytes().expect("retrieve existing body").as_ref(), payload.as_slice()); + + let resp = client + .get(format!("{base}/retrieve?id=999999999")) + .send() + .expect("retrieve missing failed"); + assert_eq!(resp.status().as_u16(), 404); + + let resp = client + .get(format!("{base}/retrieve?id=not-a-u64")) + .send() + .expect("retrieve invalid failed"); + assert!(resp.status().is_server_error()); +} From ae3dfa3cacae1ab6c576ab7d7688af597d1232a2 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 10 Apr 2026 12:59:36 -0400 Subject: [PATCH 08/47] fmt --- crates/smoketests/tests/smoketests/http_routes.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 9f800351531..975c549c62b 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -228,7 +228,10 @@ fn http_routes_pr_example_round_trip() { .send() .expect("retrieve existing failed"); assert!(resp.status().is_success()); - assert_eq!(resp.bytes().expect("retrieve existing body").as_ref(), payload.as_slice()); + assert_eq!( + resp.bytes().expect("retrieve existing body").as_ref(), + payload.as_slice() + ); let resp = client .get(format!("{base}/retrieve?id=999999999")) From f527c101a30eb873249c8a4b4a46d2d46e1bb8d3 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 13 Apr 2026 12:38:34 -0400 Subject: [PATCH 09/47] Regen C# moduledef to make `check-diff.sh` test pass --- .../Runtime/Internal/Autogen/HttpMethod.g.cs | 23 +++++++++++ .../Runtime/Internal/Autogen/MethodOrAny.g.cs | 15 +++++++ .../Autogen/RawHttpHandlerDefV10.g.cs | 29 +++++++++++++ .../Internal/Autogen/RawHttpRouteDefV10.g.cs | 41 +++++++++++++++++++ .../Autogen/RawModuleDefV10Section.g.cs | 4 +- 5 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 crates/bindings-csharp/Runtime/Internal/Autogen/HttpMethod.g.cs create mode 100644 crates/bindings-csharp/Runtime/Internal/Autogen/MethodOrAny.g.cs create mode 100644 crates/bindings-csharp/Runtime/Internal/Autogen/RawHttpHandlerDefV10.g.cs create mode 100644 crates/bindings-csharp/Runtime/Internal/Autogen/RawHttpRouteDefV10.g.cs diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/HttpMethod.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/HttpMethod.g.cs new file mode 100644 index 00000000000..1cee678edc3 --- /dev/null +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/HttpMethod.g.cs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; + +namespace SpacetimeDB.Internal +{ + [SpacetimeDB.Type] + public partial record HttpMethod : SpacetimeDB.TaggedEnum<( + SpacetimeDB.Unit Get, + SpacetimeDB.Unit Head, + SpacetimeDB.Unit Post, + SpacetimeDB.Unit Put, + SpacetimeDB.Unit Delete, + SpacetimeDB.Unit Connect, + SpacetimeDB.Unit Options, + SpacetimeDB.Unit Trace, + SpacetimeDB.Unit Patch, + string Extension + )>; +} diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/MethodOrAny.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/MethodOrAny.g.cs new file mode 100644 index 00000000000..d5c404c63d2 --- /dev/null +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/MethodOrAny.g.cs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; + +namespace SpacetimeDB.Internal +{ + [SpacetimeDB.Type] + public partial record MethodOrAny : SpacetimeDB.TaggedEnum<( + SpacetimeDB.Unit Any, + HttpMethod Method + )>; +} diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawHttpHandlerDefV10.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawHttpHandlerDefV10.g.cs new file mode 100644 index 00000000000..61260d08761 --- /dev/null +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawHttpHandlerDefV10.g.cs @@ -0,0 +1,29 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Internal +{ + [SpacetimeDB.Type] + [DataContract] + public sealed partial class RawHttpHandlerDefV10 + { + [DataMember(Name = "source_name")] + public string SourceName; + + public RawHttpHandlerDefV10(string SourceName) + { + this.SourceName = SourceName; + } + + public RawHttpHandlerDefV10() + { + this.SourceName = ""; + } + } +} diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawHttpRouteDefV10.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawHttpRouteDefV10.g.cs new file mode 100644 index 00000000000..affa2b9a5b2 --- /dev/null +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawHttpRouteDefV10.g.cs @@ -0,0 +1,41 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Internal +{ + [SpacetimeDB.Type] + [DataContract] + public sealed partial class RawHttpRouteDefV10 + { + [DataMember(Name = "handler_function")] + public string HandlerFunction; + [DataMember(Name = "method")] + public MethodOrAny Method; + [DataMember(Name = "path")] + public string Path; + + public RawHttpRouteDefV10( + string HandlerFunction, + MethodOrAny Method, + string Path + ) + { + this.HandlerFunction = HandlerFunction; + this.Method = Method; + this.Path = Path; + } + + public RawHttpRouteDefV10() + { + this.HandlerFunction = ""; + this.Method = null!; + this.Path = ""; + } + } +} diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs index 1a299f93c3a..6706fb278f0 100644 --- a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs @@ -19,6 +19,8 @@ public partial record RawModuleDefV10Section : SpacetimeDB.TaggedEnum<( System.Collections.Generic.List LifeCycleReducers, System.Collections.Generic.List RowLevelSecurity, SpacetimeDB.CaseConversionPolicy CaseConversionPolicy, - ExplicitNames ExplicitNames + ExplicitNames ExplicitNames, + System.Collections.Generic.List HttpHandlers, + System.Collections.Generic.List HttpRoutes )>; } From 3df5ab24693167850deaf332392c0016265ee2fb Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 14 Apr 2026 12:37:18 -0400 Subject: [PATCH 10/47] Add bindings UI test for HTTP handlers And relatedly, make the bindings tests run with `--feature unstable` in CI. I was sadly unable to find a better construction to emit tyck code from the macros than the existing one. I also did a drive-by fix for an invalid doctest on `HttpClient`. --- crates/bindings-macro/src/http.rs | 4 - crates/bindings/src/http.rs | 6 +- crates/bindings/tests/ui/http_handlers.rs | 68 ++++++ crates/bindings/tests/ui/http_handlers.stderr | 205 ++++++++++++++++++ tools/ci/src/main.rs | 14 ++ 5 files changed, 290 insertions(+), 7 deletions(-) create mode 100644 crates/bindings/tests/ui/http_handlers.rs create mode 100644 crates/bindings/tests/ui/http_handlers.stderr diff --git a/crates/bindings-macro/src/http.rs b/crates/bindings-macro/src/http.rs index c177a5754c1..fd81641fb74 100644 --- a/crates/bindings-macro/src/http.rs +++ b/crates/bindings-macro/src/http.rs @@ -18,7 +18,6 @@ pub(crate) fn handler_impl(args: TokenStream, original_function: &ItemFn) -> syn assert_only_lifetime_generics(original_function, "http handlers")?; - // TODO(error-reporting): Prefer emitting tyck code rather than checking in the macro. let typed_args = extract_typed_args(original_function)?; if typed_args.len() != 2 { return Err(syn::Error::new_spanned( @@ -31,7 +30,6 @@ pub(crate) fn handler_impl(args: TokenStream, original_function: &ItemFn) -> syn let first_arg_ty = &arg_tys[0]; let second_arg_ty = &arg_tys[1]; - // TODO(error-reporting): Prefer emitting tyck code rather than checking in the macro. let ret_ty = match &original_function.sig.output { ReturnType::Type(_, t) => t.as_ref(), ReturnType::Default => { @@ -68,8 +66,6 @@ pub(crate) fn handler_impl(args: TokenStream, original_function: &ItemFn) -> syn }; const _: () = { - // TODO(error-reporting): It should be sufficient to just cast the function to a particular `fn` type, - // rather than doing all this stuff with particular args implementing traits. fn _assert_args #lifetime_params () #lifetime_where_clause { let _ = <#first_arg_ty as spacetimedb::rt::HttpHandlerContextArg>::_ITEM; let _ = <#second_arg_ty as spacetimedb::rt::HttpHandlerRequestArg>::_ITEM; diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index 89fe4cbf70f..d1d9da49fe7 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -271,8 +271,8 @@ impl HttpClient { /// 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, http::Timeout}; + /// ```no_run + /// # use spacetimedb::{procedure, ProcedureContext, TimeDuration, http::{Timeout, Request}}; /// # use std::time::Duration; /// # #[procedure] /// # fn post_somewhere(ctx: &mut ProcedureContext) { @@ -281,7 +281,7 @@ impl HttpClient { /// .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))) + /// .extension(Timeout::from(TimeDuration::from(Duration::from_millis(100)))) /// .body("This is the body of the HTTP request") /// .expect("Building `Request` object failed"); /// diff --git a/crates/bindings/tests/ui/http_handlers.rs b/crates/bindings/tests/ui/http_handlers.rs new file mode 100644 index 00000000000..5d75ac58293 --- /dev/null +++ b/crates/bindings/tests/ui/http_handlers.rs @@ -0,0 +1,68 @@ +use spacetimedb::http::{handler, router, Request, Response, Router}; +use spacetimedb::{table, HandlerContext, ProcedureContext, Table}; + +#[handler] +fn handler_no_args() -> Response { + todo!() +} + +#[handler] +fn handler_immutable_ctx(_ctx: &HandlerContext, _req: Request) -> Response { + todo!() +} + +#[handler] +fn handler_wrong_ctx(_ctx: &mut ProcedureContext, _req: Request) -> Response { + todo!() +} + +#[handler] +fn handler_no_request_arg(_ctx: &mut HandlerContext) -> Response { + todo!() +} + +#[handler] +fn handler_wrong_request_arg_type(_ctx: &mut HandlerContext, _req: u32) -> Response { + todo!() +} + +#[handler] +fn handler_no_return_type(_ctx: &mut HandlerContext, _req: Request) { + todo!() +} + +#[handler] +fn handler_wrong_return_type(_ctx: &mut HandlerContext, _req: Request) -> u32 { + todo!() +} + +#[handler] +fn handler_no_sender(ctx: &mut HandlerContext, _req: Request) -> Response { + let _sender = ctx.sender(); + let _conn_id = ctx.connection_id(); + todo!() +} + +#[table(accessor = test_table)] +struct TestTable { + data: u32, +} + +#[handler] +fn handler_no_db(ctx: &mut HandlerContext, _req: Request) -> Response { + let _rows = ctx.db.test_table().iter(); + todo!() +} + +#[router] +static ROUTER_NOT_A_FUNCTION: Router = Router::new(); + +#[router] +fn router_fn_with_args(ctx: &mut HandlerContext) -> Router { + todo!() +} + +#[router] +fn router_fn_wrong_return_type() -> u32 { + todo!() +} diff --git a/crates/bindings/tests/ui/http_handlers.stderr b/crates/bindings/tests/ui/http_handlers.stderr new file mode 100644 index 00000000000..a3faf805171 --- /dev/null +++ b/crates/bindings/tests/ui/http_handlers.stderr @@ -0,0 +1,205 @@ +error: HTTP handlers must take exactly two arguments + --> tests/ui/http_handlers.rs:5:1 + | +5 | fn handler_no_args() -> Response { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: HTTP handlers must take exactly two arguments + --> tests/ui/http_handlers.rs:20:1 + | +20 | fn handler_no_request_arg(_ctx: &mut HandlerContext) -> Response { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: HTTP handlers must return `spacetimedb::http::Response` + --> tests/ui/http_handlers.rs:30:1 + | +30 | fn handler_no_return_type(_ctx: &mut HandlerContext, _req: Request) { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: expected `fn` + --> tests/ui/http_handlers.rs:58:1 + | +58 | static ROUTER_NOT_A_FUNCTION: Router = Router::new(); + | ^^^^^^ + +error: HTTP router functions must take no arguments + --> tests/ui/http_handlers.rs:61:1 + | +61 | fn router_fn_with_args(ctx: &mut HandlerContext) -> Router { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +warning: unused import: `Router` + --> tests/ui/http_handlers.rs:1:61 + | +1 | use spacetimedb::http::{handler, router, Request, Response, Router}; + | ^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + +error[E0601]: `main` function not found in crate `$CRATE` + --> tests/ui/http_handlers.rs:68:2 + | +68 | } + | ^ consider adding a `main` function to `$DIR/tests/ui/http_handlers.rs` + +error[E0308]: mismatched types + --> tests/ui/http_handlers.rs:10:4 + | + 9 | #[handler] + | ---------- arguments to this function are incorrect +10 | fn handler_immutable_ctx(_ctx: &HandlerContext, _req: Request) -> Response { + | ^^^^^^^^^^^^^^^^^^^^^ types differ in mutability + | + = note: expected fn pointer `for<'a> fn(&'a mut HandlerContext, http::request::Request) -> http::response::Response` + found fn item `for<'a> fn(&'a HandlerContext, http::request::Request) -> http::response::Response {__spacetimedb_http_handler_handler_immutable_ctx}` +note: function defined here + --> src/rt.rs + | + | pub fn register_http_handler(name: &'static str, handler: HttpHandlerFn) { + | ^^^^^^^^^^^^^^^^^^^^^ + +error[E0277]: the first argument of an HTTP handler must be `&mut HandlerContext` + --> tests/ui/http_handlers.rs:10:32 + | +10 | fn handler_immutable_ctx(_ctx: &HandlerContext, _req: Request) -> Response { + | ^^^^^^^^^^^^^^^ first argument must be `&mut HandlerContext` + | + = help: the trait `HttpHandlerContextArg` is not implemented for `&HandlerContext` +help: the trait `HttpHandlerContextArg` is implemented for `&mut HandlerContext` + --> src/rt.rs + | + | impl HttpHandlerContextArg for &mut HandlerContext {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = note: `HttpHandlerContextArg` is implemented for `&mut HandlerContext`, but not for `&HandlerContext` + +error[E0308]: mismatched types + --> tests/ui/http_handlers.rs:15:4 + | +14 | #[handler] + | ---------- arguments to this function are incorrect +15 | fn handler_wrong_ctx(_ctx: &mut ProcedureContext, _req: Request) -> Response { + | ^^^^^^^^^^^^^^^^^ expected fn pointer, found fn item + | + = note: expected fn pointer `for<'a> fn(&'a mut HandlerContext, http::request::Request) -> http::response::Response` + found fn item `for<'a> fn(&'a mut ProcedureContext, http::request::Request) -> http::response::Response {__spacetimedb_http_handler_handler_wrong_ctx}` +note: function defined here + --> src/rt.rs + | + | pub fn register_http_handler(name: &'static str, handler: HttpHandlerFn) { + | ^^^^^^^^^^^^^^^^^^^^^ + +error[E0277]: the first argument of an HTTP handler must be `&mut HandlerContext` + --> tests/ui/http_handlers.rs:15:28 + | +15 | fn handler_wrong_ctx(_ctx: &mut ProcedureContext, _req: Request) -> Response { + | ^^^^^^^^^^^^^^^^^^^^^ first argument must be `&mut HandlerContext` + | + = help: the trait `HttpHandlerContextArg` is not implemented for `&mut ProcedureContext` +help: the trait `HttpHandlerContextArg` is implemented for `&mut HandlerContext` + --> src/rt.rs + | + | impl HttpHandlerContextArg for &mut HandlerContext {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error[E0308]: mismatched types + --> tests/ui/http_handlers.rs:25:4 + | +24 | #[handler] + | ---------- arguments to this function are incorrect +25 | fn handler_wrong_request_arg_type(_ctx: &mut HandlerContext, _req: u32) -> Response { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected fn pointer, found fn item + | + = note: expected fn pointer `for<'a> fn(&'a mut HandlerContext, http::request::Request) -> http::response::Response` + found fn item `for<'a> fn(&'a mut HandlerContext, u32) -> http::response::Response {__spacetimedb_http_handler_handler_wrong_request_arg_type}` +note: function defined here + --> src/rt.rs + | + | pub fn register_http_handler(name: &'static str, handler: HttpHandlerFn) { + | ^^^^^^^^^^^^^^^^^^^^^ + +error[E0277]: the second argument of an HTTP handler must be `spacetimedb::http::Request` + --> tests/ui/http_handlers.rs:25:68 + | +25 | fn handler_wrong_request_arg_type(_ctx: &mut HandlerContext, _req: u32) -> Response { + | ^^^ the trait `HttpHandlerRequestArg` is not implemented for `u32` + | +help: the trait `HttpHandlerRequestArg` is implemented for `http::request::Request` + --> src/rt.rs + | + | impl HttpHandlerRequestArg for crate::http::Request {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error[E0308]: mismatched types + --> tests/ui/http_handlers.rs:35:4 + | +34 | #[handler] + | ---------- arguments to this function are incorrect +35 | fn handler_wrong_return_type(_ctx: &mut HandlerContext, _req: Request) -> u32 { + | ^^^^^^^^^^^^^^^^^^^^^^^^^ expected fn pointer, found fn item + | + = note: expected fn pointer `for<'a> fn(&'a mut HandlerContext, http::request::Request) -> http::response::Response` + found fn item `for<'a> fn(&'a mut HandlerContext, http::request::Request) -> u32 {__spacetimedb_http_handler_handler_wrong_return_type}` +note: function defined here + --> src/rt.rs + | + | pub fn register_http_handler(name: &'static str, handler: HttpHandlerFn) { + | ^^^^^^^^^^^^^^^^^^^^^ + +error[E0277]: HTTP handlers must return `spacetimedb::http::Response` + --> tests/ui/http_handlers.rs:35:75 + | +35 | fn handler_wrong_return_type(_ctx: &mut HandlerContext, _req: Request) -> u32 { + | ^^^ the trait `HttpHandlerReturn` is not implemented for `u32` + | +help: the trait `HttpHandlerReturn` is implemented for `http::response::Response` + --> src/rt.rs + | + | impl HttpHandlerReturn for crate::http::Response {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error[E0599]: no method named `sender` found for mutable reference `&mut HandlerContext` in the current scope + --> tests/ui/http_handlers.rs:41:23 + | +41 | let _sender = ctx.sender(); + | ^^^^^^ method not found in `&mut HandlerContext` + +error[E0599]: no method named `connection_id` found for mutable reference `&mut HandlerContext` in the current scope + --> tests/ui/http_handlers.rs:42:24 + | +42 | let _conn_id = ctx.connection_id(); + | ^^^^^^^^^^^^^ method not found in `&mut HandlerContext` + +error[E0609]: no field `db` on type `&mut HandlerContext` + --> tests/ui/http_handlers.rs:53:21 + | +53 | let _rows = ctx.db.test_table().iter(); + | ^^ unknown field + | + = note: available fields are: `timestamp`, `http` + +error[E0308]: mismatched types + --> tests/ui/http_handlers.rs:66:4 + | +65 | #[router] + | --------- expected due to this +66 | fn router_fn_wrong_return_type() -> u32 { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected fn pointer, found fn item + | + = note: expected fn pointer `fn() -> Router` + found fn item `fn() -> u32 {router_fn_wrong_return_type}` + +error[E0308]: mismatched types + --> tests/ui/http_handlers.rs:66:4 + | +65 | #[router] + | --------- arguments to this function are incorrect +66 | fn router_fn_wrong_return_type() -> u32 { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected fn pointer, found fn item + | + = note: expected fn pointer `fn() -> Router` + found fn item `fn() -> u32 {router_fn_wrong_return_type}` +note: function defined here + --> src/rt.rs + | + | pub fn register_http_router(build: fn() -> crate::http::Router) { + | ^^^^^^^^^^^^^^^^^^^^ diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index cef4b6fda34..bddb31b4ab7 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -318,12 +318,26 @@ fn main() -> Result<()> { "spacetimedb-smoketests", "--exclude", "spacetimedb-sdk", + "--exclude", + "spacetimedb", "--", "--test-threads=2", "--skip", "unreal" ) .run()?; + // Bindings snapshot tests rely on the unstable feature. + cmd!( + "cargo", + "test", + "-p", + "spacetimedb", + "--features", + "unstable", + "--", + "--test-threads=2", + ) + .run()?; // SDK procedure tests intentionally make localhost HTTP requests. cmd!( "cargo", From 797f3dca264403e603547b852e8334de89038439 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 14 Apr 2026 12:46:19 -0400 Subject: [PATCH 11/47] Remove unnecessary duplicated binding in macro output --- crates/bindings-macro/src/http.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/bindings-macro/src/http.rs b/crates/bindings-macro/src/http.rs index fd81641fb74..0d5d86a5c06 100644 --- a/crates/bindings-macro/src/http.rs +++ b/crates/bindings-macro/src/http.rs @@ -98,9 +98,7 @@ pub(crate) fn router_impl(args: TokenStream, original_function: &ItemFn) -> syn: const _: () = { fn _assert_router() { - // TODO(cleanup): Why two bindings here? let _f: fn() -> spacetimedb::http::Router = #func_name; - let _ = _f; } }; From 151f2248146bafa7c6b78b98744d5613293608a6 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 15 Apr 2026 11:05:00 -0400 Subject: [PATCH 12/47] Be more restrictive about characters allowed in HTTP routes Restrict HTTP routes to allow only characters permitted by a new function, `spacetimedb_lib::http::character_is_acceptable_for_route_path`, which currently is restricted to ASCII lowercase, ASCII digit and `-_~/`. (Slash is allowed because it's the path segment separator character.) This is checked both when constructing the `Router` and during `ModuleDef` validation. --- Cargo.lock | 1 - crates/bindings/src/http.rs | 17 +++--- crates/lib/src/http.rs | 16 ++++++ crates/schema/Cargo.toml | 6 -- crates/schema/src/def/validate/v10.rs | 83 +++++++++++++++++++++++---- crates/schema/src/error.rs | 6 +- 6 files changed, 101 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 948c3379545..e660bb7d644 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8470,7 +8470,6 @@ dependencies = [ "derive_more 0.99.20", "enum-as-inner", "enum-map", - "http 1.3.1", "indexmap 2.12.0", "insta", "itertools 0.12.1", diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index d1d9da49fe7..dfa078bb7f7 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -6,14 +6,16 @@ //! while [`send`](HttpClient::send) allows more complex requests with headers, bodies and other methods. use bytes::Bytes; -use std::str::FromStr; use crate::{ rt::{read_bytes_source_as, read_bytes_source_into}, IterBuf, }; use spacetimedb_lib::db::raw_def::v10::MethodOrAny; -use spacetimedb_lib::{bsatn, http as st_http, TimeDuration}; +use spacetimedb_lib::http::{ + self as st_http, character_is_acceptable_for_route_path, ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION, +}; +use spacetimedb_lib::{bsatn, TimeDuration}; pub type Request = http::Request; @@ -228,12 +230,11 @@ fn assert_valid_path(path: &str) { if !path.starts_with('/') { panic!("Route paths must start with `/`: {path}"); } - // TODO: detect more suspicious characters. https://stackoverflow.com/a/695467 seems like a reasonable set. - if path.contains('?') || path.contains('#') { - panic!("Route paths must not include `?` or `#`: {path}"); - } - if http::uri::PathAndQuery::from_str(path).is_err() { - panic!("Route path is not a valid URL path: {path}"); + if !path.chars().all(character_is_acceptable_for_route_path) { + panic!( + "Route paths may contain only {}: {path}", + ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION + ); } } diff --git a/crates/lib/src/http.rs b/crates/lib/src/http.rs index b9ed6331ea1..41ef49fbd63 100644 --- a/crates/lib/src/http.rs +++ b/crates/lib/src/http.rs @@ -182,3 +182,19 @@ pub struct ResponseAndBody { pub response: Response, pub body: Bytes, } + +/// True if `c` is a valid character to appear in the path of a user-defined HTTP route. +/// +/// We permit only lowercase ASCII letters, ASCII digits, and `-_~/`. +/// `/` is allowed specifically because it's the segment separator character. +/// `-_~` seem harmless enough. +/// +/// We've chosen an intentionally very restrictive set so that we can assign meaning to other characters in the future, +/// e.g. we may want to use `*` as a wildcard, `:` or `{}` to introduce path parameters, &c. +pub fn character_is_acceptable_for_route_path(c: char) -> bool { + c.is_ascii_lowercase() || c.is_ascii_digit() || "-_~/".contains(c) +} + +/// A human-readable description of the characters accepted by [`character_is_acceptable_for_route_path`], +/// for use in error reporting. +pub const ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION: &str = "ASCII lowercase letters, digits and `-_~/`"; diff --git a/crates/schema/Cargo.toml b/crates/schema/Cargo.toml index 52cceb0243d..25ff3e3e9cc 100644 --- a/crates/schema/Cargo.toml +++ b/crates/schema/Cargo.toml @@ -16,12 +16,6 @@ spacetimedb-sats = { workspace = true, features = ["memory-usage"] } spacetimedb-data-structures.workspace = true spacetimedb-memory-usage.workspace = true spacetimedb-sql-parser.workspace = true - -# Used only for a call to `PathAndQuery::from_str(path_str).is_err()` -# in `src/def/validate/v10.rs#validate_http_route_path`. -# Possibly would be nice to eliminate this dependency. -http.workspace = true - anyhow.workspace = true derive_more.workspace = true indexmap.workspace = true diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 691aebe6a99..d7cb986bf1d 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -1,10 +1,10 @@ -use http::uri::PathAndQuery; use spacetimedb_data_structures::error_stream::ErrorStream; use spacetimedb_data_structures::map::HashMap; use spacetimedb_lib::bsatn::Deserializer; use spacetimedb_lib::db::raw_def::v10::*; use spacetimedb_lib::db::view::{extract_view_return_product_type_ref, ViewKind}; use spacetimedb_lib::de::DeserializeSeed as _; +use spacetimedb_lib::http::character_is_acceptable_for_route_path; use spacetimedb_sats::{Typespace, WithTypespace}; use crate::def::validate::v9::{ @@ -15,8 +15,6 @@ use crate::def::*; use crate::error::ValidationError; use crate::type_for_generate::ProductTypeDef; use crate::{def::validate::Result, error::TypeLocation}; -use std::str::FromStr; - // Utitility struct to look up canonical names for tables, functions, and indexes based on the // explicit names provided in the `RawModuleDefV10`. #[derive(Default)] @@ -361,11 +359,7 @@ fn change_scheduled_functions_and_lifetimes_visibility( fn validate_http_route_path(path: &RawIdentifier) -> Result<()> { let path_str = path.as_ref(); - // TODO: detect more suspicious characters. https://stackoverflow.com/a/695467 seems like a reasonable set. - if !path_str.starts_with('/') || path_str.contains('?') || path_str.contains('#') { - return Err(ValidationError::InvalidHttpRoutePath { path: path.clone() }.into()); - } - if PathAndQuery::from_str(path_str).is_err() { + if !path_str.starts_with('/') || !path_str.chars().all(character_is_acceptable_for_route_path) { return Err(ValidationError::InvalidHttpRoutePath { path: path.clone() }.into()); } Ok(()) @@ -1816,13 +1810,78 @@ mod tests { builder.add_http_handler("handle"); builder.add_http_route("handle", MethodOrAny::Any, "no-slash"); - // TODO(testing): Extend this test with more invalid paths. + for path in [ + "/Uppercase", + "/caf\u{e9}", + "/ampersand&", + "/dollar$", + "/plus+", + "/comma,", + "/colon:", + "/semicolon;", + "/equals=", + "/question?", + "/at@", + "/hash#", + "/space here", + "/less<", + "/greater>", + "/left[", + "/right]", + "/left-brace{", + "/right-brace}", + "/pipe|", + "/backslash\\", + "/caret^", + "/percent%", + ] { + builder.add_http_route("handle", MethodOrAny::Any, path); + } let result: Result = builder.finish().try_into(); - expect_error_matching!(result, ValidationError::InvalidHttpRoutePath { path } => { - path.as_ref() == "no-slash" - }); + if let Err(errs) = result { + let mut paths = errs + .iter() + .filter_map(|err| match err { + ValidationError::InvalidHttpRoutePath { path } => Some(path.as_ref()), + _ => None, + }) + .collect::>(); + paths.sort_unstable(); + + let mut expected = vec![ + "no-slash", + "/Uppercase", + "/caf\u{e9}", + "/ampersand&", + "/dollar$", + "/plus+", + "/comma,", + "/colon:", + "/semicolon;", + "/equals=", + "/question?", + "/at@", + "/hash#", + "/space here", + "/less<", + "/greater>", + "/left[", + "/right]", + "/left-brace{", + "/right-brace}", + "/pipe|", + "/backslash\\", + "/caret^", + "/percent%", + ]; + expected.sort_unstable(); + + assert_eq!(paths, expected); + } else { + panic!("expected invalid HTTP route path errors"); + } } #[test] diff --git a/crates/schema/src/error.rs b/crates/schema/src/error.rs index 5db1c0c6fde..f815de5196e 100644 --- a/crates/schema/src/error.rs +++ b/crates/schema/src/error.rs @@ -1,5 +1,6 @@ use spacetimedb_data_structures::error_stream::ErrorStream; use spacetimedb_lib::db::raw_def::v10::MethodOrAny; +use spacetimedb_lib::http::ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION; use spacetimedb_lib::db::raw_def::v9::{Lifecycle, RawScopedTypeNameV9}; use spacetimedb_lib::{ProductType, SumType}; use spacetimedb_primitives::{ColId, ColList, ColSet}; @@ -140,7 +141,10 @@ pub enum ValidationError { DuplicateHttpHandlerName { name: Identifier }, #[error("HTTP route duplicates method {method:?} for path {path}")] DuplicateHttpRoute { path: RawIdentifier, method: MethodOrAny }, - #[error("HTTP route path `{path}` is invalid")] + #[error( + "HTTP route path `{path}` is invalid; allowed characters are {allowed}", + allowed = ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION + )] InvalidHttpRoutePath { path: RawIdentifier }, #[error("HTTP route refers to unknown HTTP handler `{handler}`")] MissingHttpHandler { handler: RawIdentifier }, From 9892e9ba221a9e71287990383960ae4429fdc7ba Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 15 Apr 2026 11:16:37 -0400 Subject: [PATCH 13/47] fmt --- crates/schema/src/error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/schema/src/error.rs b/crates/schema/src/error.rs index f815de5196e..33abb0c1866 100644 --- a/crates/schema/src/error.rs +++ b/crates/schema/src/error.rs @@ -1,7 +1,7 @@ use spacetimedb_data_structures::error_stream::ErrorStream; use spacetimedb_lib::db::raw_def::v10::MethodOrAny; -use spacetimedb_lib::http::ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION; use spacetimedb_lib::db::raw_def::v9::{Lifecycle, RawScopedTypeNameV9}; +use spacetimedb_lib::http::ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION; use spacetimedb_lib::{ProductType, SumType}; use spacetimedb_primitives::{ColId, ColList, ColSet}; use spacetimedb_sats::algebraic_type::fmt::fmt_algebraic_type; From f976a0004f409144ed65c7470b4f75d8bb62d165 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 15 Apr 2026 11:29:06 -0400 Subject: [PATCH 14/47] clippy --- crates/bindings/src/http.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index dfa078bb7f7..ab360ca093c 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -5,17 +5,17 @@ //! 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; - use crate::{ rt::{read_bytes_source_as, read_bytes_source_into}, IterBuf, }; +use bytes::Bytes; use spacetimedb_lib::db::raw_def::v10::MethodOrAny; use spacetimedb_lib::http::{ self as st_http, character_is_acceptable_for_route_path, ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION, }; use spacetimedb_lib::{bsatn, TimeDuration}; +use std::str::FromStr; pub type Request = http::Request; From 7e20edaff169ea3b7201bb11addaf7884d2ec318 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 17 Apr 2026 15:38:37 -0400 Subject: [PATCH 15/47] Use updated Axum syntax for wildcards --- crates/client-api/src/routes/database.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 8354e8807c0..6afffc1b173 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -1445,7 +1445,7 @@ where // Keep these routes merged separately from the authenticated database router. let http_route_router = axum::Router::::new() .route("/{name_or_identity}/route", any(handle_http_route_root::)) - .route("/{name_or_identity}/route/*path", any(handle_http_route::)); + .route("/{name_or_identity}/route/{*path}", any(handle_http_route::)); axum::Router::new() .merge(authed_root_router) From 999a7c31755a4e43d36b5e7a4d247ec9d3a05eec Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 21 Apr 2026 10:55:23 -0400 Subject: [PATCH 16/47] Fix name collision with `const index` or `const name` colocated with `#[table]` This is an old bug that isn't actually related to HTTP handlers at all, except that I encountered it while testing an HTTP handler named `index` or `name`. It's a simple enough fix that I'm slipping it in to this PR. --- crates/bindings-macro/src/sats.rs | 36 ++++++++++++++++--- .../tests/pass/table_index_name_conflict.rs | 20 +++++++++++ .../tests/pass/table_name_name_conflict.rs | 19 ++++++++++ crates/bindings/tests/ui.rs | 1 + 4 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 crates/bindings/tests/pass/table_index_name_conflict.rs create mode 100644 crates/bindings/tests/pass/table_name_name_conflict.rs diff --git a/crates/bindings-macro/src/sats.rs b/crates/bindings-macro/src/sats.rs index 1902592fcbd..140bb36f945 100644 --- a/crates/bindings-macro/src/sats.rs +++ b/crates/bindings-macro/src/sats.rs @@ -1,3 +1,7 @@ +//! When editing generated code in this module, use `__`-prefixed reserved names +//! for macro-emitted local bindings and helper items to avoid collisions with +//! user-defined items at the expansion site. + extern crate core; extern crate proc_macro; @@ -252,11 +256,13 @@ pub(crate) fn derive_satstype(ty: &SatsType<'_>) -> TokenStream { #[automatically_derived] impl #impl_generics #krate::SpacetimeType for #name #ty_generics #where_clause { + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. fn make_type(__typespace: &mut S) -> #krate::sats::AlgebraicType { #krate::sats::typespace::TypespaceBuilder::add( __typespace, core::any::TypeId::of::<#name #typeid_ty_generics>(), Some(#ty_name), + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. |__typespace| #typ, ) } @@ -407,6 +413,7 @@ pub(crate) fn derive_deserialize(ty: &SatsType<'_>) -> TokenStream { } } + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. struct __ProductVisitor #impl_generics #where_clause { _marker: std::marker::PhantomData #name #ty_generics>, } @@ -435,8 +442,10 @@ pub(crate) fn derive_deserialize(ty: &SatsType<'_>) -> TokenStream { )* Ok(()) } + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. fn visit_named_product>(self, mut __prod: A) -> Result { #(let mut #field_names = None;)* + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. while let Some(__field) = #spacetimedb_lib::de::NamedProductAccess::get_field_ident(&mut __prod, Self { _marker: std::marker::PhantomData, })? { @@ -454,8 +463,10 @@ pub(crate) fn derive_deserialize(ty: &SatsType<'_>) -> TokenStream { #field_names.ok_or_else(|| #spacetimedb_lib::de::Error::missing_field(#iter_n4, Some(#field_strings), &self))?,)* }) } + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. fn validate_named_product>(self, mut __prod: A) -> Result<(), A::Error> { #(let mut #field_names = false;)* + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. while let Some(__field) = #spacetimedb_lib::de::NamedProductAccess::get_field_ident(&mut __prod, Self { _marker: std::marker::PhantomData, })? { @@ -485,15 +496,18 @@ pub(crate) fn derive_deserialize(ty: &SatsType<'_>) -> TokenStream { [#(#field_strings),*].into_iter().map(Some) } - fn visit<__E: #spacetimedb_lib::de::Error>(self, name: &str) -> Result { - match name { + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. + fn visit<__E: #spacetimedb_lib::de::Error>(self, __name: &str) -> Result { + match __name { #(#field_strings => Ok(__ProductFieldIdent::#field_names),)* - _ => Err(#spacetimedb_lib::de::Error::unknown_field_name(name, &self)), + _ => Err(#spacetimedb_lib::de::Error::unknown_field_name(__name, &self)), } } - fn visit_seq(self, index: usize) -> Self::Output { - match index { + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. + fn visit_seq(self, __index: usize) -> Self::Output { + match __index { #(#iter_n7 => __ProductFieldIdent::#field_names,)* _ => core::unreachable!(), } @@ -501,6 +515,7 @@ pub(crate) fn derive_deserialize(ty: &SatsType<'_>) -> TokenStream { } #[allow(non_camel_case_types)] + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. enum __ProductFieldIdent { #(#field_names,)* } @@ -555,6 +570,7 @@ pub(crate) fn derive_deserialize(ty: &SatsType<'_>) -> TokenStream { } } + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. struct __SumVisitor #impl_generics #where_clause { _marker: std::marker::PhantomData #name #ty_generics>, } @@ -566,14 +582,18 @@ pub(crate) fn derive_deserialize(ty: &SatsType<'_>) -> TokenStream { Some(#tuple_name) } + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. fn visit_sum>(self, __data: A) -> Result { + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. let (__variant, __access) = __data.variant(self)?; match __variant { #(#arms)* } } + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. fn validate_sum>(self, __data: A) -> Result<(), A::Error> { + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. let (__variant, __access) = __data.variant(self)?; match __variant { #(#arms_validate)* @@ -583,6 +603,7 @@ pub(crate) fn derive_deserialize(ty: &SatsType<'_>) -> TokenStream { } #[allow(non_camel_case_types)] + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. enum __Variant { #(#variant_idents,)* } @@ -594,12 +615,14 @@ pub(crate) fn derive_deserialize(ty: &SatsType<'_>) -> TokenStream { [#(#variant_names,)*].into_iter() } + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. fn visit_tag(self, __tag: u8) -> Result { match __tag { #(#tags => Ok(__Variant::#variant_idents),)* _ => Err(#spacetimedb_lib::de::Error::unknown_variant_tag(__tag, &self)), } } + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. fn visit_name(self, __name: &str) -> Result { match __name { #(#variant_names => Ok(__Variant::#variant_idents),)* @@ -664,6 +687,7 @@ pub(crate) fn derive_serialize(ty: &SatsType) -> TokenStream { let fieldnamestrings = fields.iter().map(|field| field.name.as_ref().unwrap()); let nfields = fields.len(); quote! { + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. let mut __prod = __serializer.serialize_named_product(#nfields)?; #(#spacetimedb_lib::ser::SerializeNamedProduct::serialize_element::<#tys>(&mut __prod, Some(#fieldnamestrings), &self.#fieldnames)?;)* #spacetimedb_lib::ser::SerializeNamedProduct::end(__prod) @@ -675,6 +699,7 @@ pub(crate) fn derive_serialize(ty: &SatsType) -> TokenStream { let tag = i as u8; if let (Some(member), Some(ty)) = (&var.member, var.ty) { quote_spanned! {ty.span()=> + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. Self::#name { #member: __variant } => __serializer.serialize_variant::<#ty>(#tag, Some(#name_str), __variant), } } else { @@ -692,6 +717,7 @@ pub(crate) fn derive_serialize(ty: &SatsType) -> TokenStream { quote! { impl #impl_generics #spacetimedb_lib::ser::Serialize for #name #ty_generics #where_clause { #fast_body + // __ reserved name for binding to prevent name conflicts. See module-level doc comment. fn serialize(&self, __serializer: S) -> Result { #body } diff --git a/crates/bindings/tests/pass/table_index_name_conflict.rs b/crates/bindings/tests/pass/table_index_name_conflict.rs new file mode 100644 index 00000000000..421e2729e53 --- /dev/null +++ b/crates/bindings/tests/pass/table_index_name_conflict.rs @@ -0,0 +1,20 @@ +// This file tests that it's possible to have a value item (`fn`, `const`, or `static`) named `index` +// without introducing a name conflict due to a binding introduced by the `#[table]` macro. +// Prior to a fix, the SATS derive macros (which were invoked by `table`) introduced some bindings +// which were not in the `__` reserved namespace and had common names, +// resulting in name collisions with user code. + +use spacetimedb::http::{Request, Response}; + +#[spacetimedb::http::handler] +fn index(_ctx: &mut spacetimedb::HandlerContext, _req: Request) -> Response { + Response::new(().into()) +} + +#[spacetimedb::table(accessor = things)] +struct Thing { + #[index(btree)] + value: u32, +} + +fn main() {} diff --git a/crates/bindings/tests/pass/table_name_name_conflict.rs b/crates/bindings/tests/pass/table_name_name_conflict.rs new file mode 100644 index 00000000000..1019b3e62c0 --- /dev/null +++ b/crates/bindings/tests/pass/table_name_name_conflict.rs @@ -0,0 +1,19 @@ +// This file tests that it's possible to have a value item (`fn`, `const`, or `static`) named `name` +// without introducing a name conflict due to a binding introduced by the `#[table]` macro. +// Prior to a fix, the SATS derive macros (which were invoked by `table`) introduced some bindings +// which were not in the `__` reserved namespace and had common names, +// resulting in name collisions with user code. + +use spacetimedb::http::{Request, Response}; + +#[spacetimedb::http::handler] +fn name(_ctx: &mut spacetimedb::HandlerContext, _req: Request) -> Response { + Response::new(().into()) +} + +#[spacetimedb::table(accessor = things)] +struct Thing { + value: u32, +} + +fn main() {} diff --git a/crates/bindings/tests/ui.rs b/crates/bindings/tests/ui.rs index 870c2f95ec1..c13cfdfaeb2 100644 --- a/crates/bindings/tests/ui.rs +++ b/crates/bindings/tests/ui.rs @@ -2,4 +2,5 @@ fn ui() { let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/*.rs"); + t.pass("tests/pass/*.rs"); } From 847f98928acba7b20318a60701a4918028131ed7 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 21 Apr 2026 13:46:32 -0400 Subject: [PATCH 17/47] Accept the empty route path; distinguish between empty and root Prior to this commit, we accepted `/` as a route path, but rejected the empty string. This bound to `/v1/database/:name/route` without a trailing slahs, and `/v1/database/:name/route/` was un-bindable. With this commit, we accept both the empty string and `/`. The empty string binds to `/v1/database/:name/route` without a trailing slash, and `/` binds to `/v1/database/:name/route/` with a trailing slash. --- crates/bindings/src/http.rs | 2 +- crates/client-api/src/routes/database.rs | 12 +- crates/schema/src/def/validate/v10.rs | 4 +- .../tests/smoketests/http_routes.rs | 105 ++++++++++++++++++ 4 files changed, 120 insertions(+), 3 deletions(-) diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index ab360ca093c..488f76e4660 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -227,7 +227,7 @@ fn join_paths(prefix: &str, suffix: &str) -> String { } fn assert_valid_path(path: &str) { - if !path.starts_with('/') { + if !path.is_empty() && !path.starts_with('/') { panic!("Route paths must start with `/`: {path}"); } if !path.chars().all(character_is_acceptable_for_route_path) { diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 6afffc1b173..db99da5876d 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -238,6 +238,14 @@ pub async fn handle_http_route_root( handle_http_route_impl(worker_ctx, name_or_identity, None, request).await } +pub async fn handle_http_route_root_slash( + State(worker_ctx): State, + Path(HttpRouteRootParams { name_or_identity }): Path, + request: Request, +) -> axum::response::Result { + handle_http_route_impl(worker_ctx, name_or_identity, Some(String::new()), request).await +} + pub async fn handle_http_route( State(worker_ctx): State, Path(HttpRouteParams { name_or_identity, path }): Path, @@ -256,7 +264,8 @@ async fn handle_http_route_impl( request: Request, ) -> axum::response::Result { let handler_path = match path.as_deref() { - Some("") | None => "/".to_string(), + None => "".to_string(), + Some("") => "/".to_string(), Some(path) => format!("/{path}"), }; @@ -1445,6 +1454,7 @@ where // Keep these routes merged separately from the authenticated database router. let http_route_router = axum::Router::::new() .route("/{name_or_identity}/route", any(handle_http_route_root::)) + .route("/{name_or_identity}/route/", any(handle_http_route_root_slash::)) .route("/{name_or_identity}/route/{*path}", any(handle_http_route::)); axum::Router::new() diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index d7cb986bf1d..4313f1d6e5f 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -359,7 +359,9 @@ fn change_scheduled_functions_and_lifetimes_visibility( fn validate_http_route_path(path: &RawIdentifier) -> Result<()> { let path_str = path.as_ref(); - if !path_str.starts_with('/') || !path_str.chars().all(character_is_acceptable_for_route_path) { + if (!path_str.is_empty() && !path_str.starts_with('/')) + || !path_str.chars().all(character_is_acceptable_for_route_path) + { return Err(ValidationError::InvalidHttpRoutePath { path: path.clone() }.into()); } Ok(()) diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 975c549c62b..d43c7b3ff58 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -127,6 +127,62 @@ fn router() -> Router { } "#; +const STRICT_ROOT_ROUTING_MODULE_CODE: &str = r#" +use spacetimedb::http::{Body, Request, Response, Router}; +use spacetimedb::HandlerContext; + +#[spacetimedb::http::handler] +fn empty_root(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::new(Body::from_bytes("empty")) +} + +#[spacetimedb::http::handler] +fn slash_root(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::new(Body::from_bytes("slash")) +} + +#[spacetimedb::http::handler] +fn foo(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::new(Body::from_bytes("foo")) +} + +#[spacetimedb::http::handler] +fn foo_slash(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::new(Body::from_bytes("foo-slash")) +} + +#[spacetimedb::http::router] +fn router() -> Router { + Router::new() + .get("", empty_root) + .get("/", slash_root) + .get("/foo", foo) + .get("/foo/", foo_slash) +} +"#; + +const STRICT_NON_ROOT_ROUTING_MODULE_CODE: &str = r#" +use spacetimedb::http::{Body, Request, Response, Router}; +use spacetimedb::HandlerContext; + +#[spacetimedb::http::handler] +fn foo(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::new(Body::from_bytes("foo")) +} + +#[spacetimedb::http::handler] +fn foo_slash(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::new(Body::from_bytes("foo-slash")) +} + +#[spacetimedb::http::router] +fn router() -> Router { + Router::new() + .get("/foo", foo) + .get("/foo/", foo_slash) +} +"#; + const NO_SUCH_ROUTE_BODY: &str = "Database has not registered a handler for this route"; #[test] @@ -245,3 +301,52 @@ fn http_routes_pr_example_round_trip() { .expect("retrieve invalid failed"); assert!(resp.status().is_server_error()); } + +#[test] +fn http_routes_are_strict_for_non_root_paths() { + let test = Smoketest::builder() + .module_code(STRICT_NON_ROOT_ROUTING_MODULE_CODE) + .build(); + let identity = test.database_identity.as_ref().expect("database identity missing"); + + let base = format!("{}/v1/database/{}/route", test.server_url, identity); + let client = reqwest::blocking::Client::new(); + + let resp = client.get(format!("{base}/foo")).send().expect("foo failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("foo body"), "foo"); + + let resp = client.get(format!("{base}/foo/")).send().expect("foo slash failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("foo slash body"), "foo-slash"); + + let resp = client.get(format!("{base}//")).send().expect("double slash failed"); + assert_eq!(resp.status().as_u16(), 404); + assert_eq!(resp.text().expect("double slash body"), NO_SUCH_ROUTE_BODY); + + let resp = client + .get(format!("{base}//foo")) + .send() + .expect("double slash foo failed"); + assert_eq!(resp.status().as_u16(), 404); + assert_eq!(resp.text().expect("double slash foo body"), NO_SUCH_ROUTE_BODY); +} + +#[test] +fn http_routes_are_strict_for_root_paths() { + let test = Smoketest::builder() + .module_code(STRICT_ROOT_ROUTING_MODULE_CODE) + .build(); + let identity = test.database_identity.as_ref().expect("database identity missing"); + + let base = format!("{}/v1/database/{}/route", test.server_url, identity); + let client = reqwest::blocking::Client::new(); + + let resp = client.get(base.clone()).send().expect("empty root failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("empty root body"), "empty"); + + let resp = client.get(format!("{base}/")).send().expect("slash root failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("slash root body"), "slash"); +} From 07882478c7c56a8a0ce69ae480d941e46b2e6b5e Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 21 Apr 2026 13:52:08 -0400 Subject: [PATCH 18/47] Silence warning on non-upper-case global emitted by `#[handler]` --- crates/bindings-macro/src/http.rs | 1 + .../tests/pass/http_handler_no_style_warnings.rs | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 100644 crates/bindings/tests/pass/http_handler_no_style_warnings.rs diff --git a/crates/bindings-macro/src/http.rs b/crates/bindings-macro/src/http.rs index 0d5d86a5c06..b5cfb538bab 100644 --- a/crates/bindings-macro/src/http.rs +++ b/crates/bindings-macro/src/http.rs @@ -59,6 +59,7 @@ pub(crate) fn handler_impl(args: TokenStream, original_function: &ItemFn) -> syn Ok(quote! { #inner_fn + #[allow(non_upper_case_globals)] #vis const #func_name: spacetimedb::http::Handler = spacetimedb::http::Handler::new(#handler_name); const _: () = { diff --git a/crates/bindings/tests/pass/http_handler_no_style_warnings.rs b/crates/bindings/tests/pass/http_handler_no_style_warnings.rs new file mode 100644 index 00000000000..1e49bfc9c2a --- /dev/null +++ b/crates/bindings/tests/pass/http_handler_no_style_warnings.rs @@ -0,0 +1,10 @@ +#![deny(warnings)] + +use spacetimedb::http::{Request, Response}; + +#[spacetimedb::http::handler] +fn lowercase_handler(_ctx: &mut spacetimedb::HandlerContext, _req: Request) -> Response { + Response::new(().into()) +} + +fn main() {} From c02f795f1c7dcd139e3bcdaf1475d716367f7240 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 22 Apr 2026 12:08:39 -0400 Subject: [PATCH 19/47] Expose full URI to handlers, incl. protocol and authority --- crates/client-api/src/routes/database.rs | 60 +++++++++++++++++-- .../tests/smoketests/http_routes.rs | 29 +++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index db99da5876d..9d934e573b9 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -15,7 +15,7 @@ use crate::{ NodeDelegate, Unauthorized, }; use axum::body::{Body, Bytes}; -use axum::extract::{Path, Query, Request, State}; +use axum::extract::{OriginalUri, Path, Query, Request, State}; use axum::response::{ErrorResponse, IntoResponse}; use axum::routing::MethodRouter; use axum::Extension; @@ -233,25 +233,28 @@ pub struct HttpRouteParams { pub async fn handle_http_route_root( State(worker_ctx): State, Path(HttpRouteRootParams { name_or_identity }): Path, + OriginalUri(original_uri): OriginalUri, request: Request, ) -> axum::response::Result { - handle_http_route_impl(worker_ctx, name_or_identity, None, request).await + handle_http_route_impl(worker_ctx, name_or_identity, None, original_uri, request).await } pub async fn handle_http_route_root_slash( State(worker_ctx): State, Path(HttpRouteRootParams { name_or_identity }): Path, + OriginalUri(original_uri): OriginalUri, request: Request, ) -> axum::response::Result { - handle_http_route_impl(worker_ctx, name_or_identity, Some(String::new()), request).await + handle_http_route_impl(worker_ctx, name_or_identity, Some(String::new()), original_uri, request).await } pub async fn handle_http_route( State(worker_ctx): State, Path(HttpRouteParams { name_or_identity, path }): Path, + OriginalUri(original_uri): OriginalUri, request: Request, ) -> axum::response::Result { - handle_http_route_impl(worker_ctx, name_or_identity, Some(path), request).await + handle_http_route_impl(worker_ctx, name_or_identity, Some(path), original_uri, request).await } /// Error response body for unknown user-defined HTTP route. @@ -261,6 +264,7 @@ async fn handle_http_route_impl( worker_ctx: S, name_or_identity: NameOrIdentity, path: Option, + original_uri: http::Uri, request: Request, ) -> axum::response::Result { let handler_path = match path.as_deref() { @@ -280,12 +284,13 @@ async fn handle_http_route_impl( }; let body = body.collect().await.map_err(log_and_500)?.to_bytes(); + let forwarded_uri = reconstruct_external_uri(&original_uri, &parts.headers); let request = st_http::RequestAndBody { request: st_http::Request { method: st_method.clone(), headers: headers_to_st(parts.headers), timeout: None, - uri: parts.uri.to_string(), + uri: forwarded_uri, version: http_version_to_st(parts.version), }, body, @@ -316,6 +321,51 @@ async fn handle_http_route_impl( Ok(response.into_response()) } +/// Return the URI that would have been in the original request, including scheme, domain and full path. +/// +/// This is necessary because Axum strips the URI as it processes routing, +/// causing the request seen by the handler function to contain only the suffix that participated in routing +/// for the last service involved. +/// +/// We want to show the entire URI to the user-defined handler, so we reconstruct it based on X-Forwarded headers. +fn reconstruct_external_uri(original_uri: &http::Uri, headers: &http::HeaderMap) -> String { + if original_uri.scheme().is_some() && original_uri.authority().is_some() { + return original_uri.to_string(); + } + + let scheme = forwarded_header(headers, "x-forwarded-proto") + .or_else(|| original_uri.scheme_str().map(str::to_owned)) + .unwrap_or_else(|| "http".to_string()); + let authority = forwarded_header(headers, "x-forwarded-host") + .or_else(|| { + headers + .get(http::header::HOST) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }) + .or_else(|| original_uri.authority().map(|authority| authority.to_string())); + let path_and_query = original_uri + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or_else(|| original_uri.path()); + + if let Some(authority) = authority { + format!("{scheme}://{authority}{path_and_query}") + } else { + original_uri.to_string() + } +} + +fn forwarded_header(headers: &http::HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(',').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + fn assert_content_type_json(content_type: headers::ContentType) -> axum::response::Result<()> { if content_type != headers::ContentType::json() { Err(axum::extract::rejection::MissingJsonContentType::default().into()) diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index d43c7b3ff58..eca288a5d71 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -183,6 +183,21 @@ fn router() -> Router { } "#; +const FULL_URI_MODULE_CODE: &str = r#" +use spacetimedb::http::{Body, Request, Response, Router}; +use spacetimedb::HandlerContext; + +#[spacetimedb::http::handler] +fn echo_uri(_ctx: &mut HandlerContext, req: Request) -> Response { + Response::new(Body::from_bytes(req.uri().to_string())) +} + +#[spacetimedb::http::router] +fn router() -> Router { + Router::new().get("/echo-uri", echo_uri) +} +"#; + const NO_SUCH_ROUTE_BODY: &str = "Database has not registered a handler for this route"; #[test] @@ -350,3 +365,17 @@ fn http_routes_are_strict_for_root_paths() { assert!(resp.status().is_success()); assert_eq!(resp.text().expect("slash root body"), "slash"); } + +#[test] +fn http_handler_observes_full_external_uri() { + let test = Smoketest::builder().module_code(FULL_URI_MODULE_CODE).build(); + let identity = test.database_identity.as_ref().expect("database identity missing"); + + let base = format!("{}/v1/database/{}/route", test.server_url, identity); + let url = format!("{base}/echo-uri?alpha=beta"); + let client = reqwest::blocking::Client::new(); + + let resp = client.get(&url).send().expect("echo-uri failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("echo-uri body"), url); +} From 0dde205c198e4ba1510e5157580bca11c03ebc26 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 22 Apr 2026 12:13:44 -0400 Subject: [PATCH 20/47] Simplify passing path into handler impl --- crates/client-api/src/routes/database.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 9d934e573b9..df1410bc26b 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -236,7 +236,7 @@ pub async fn handle_http_route_root( OriginalUri(original_uri): OriginalUri, request: Request, ) -> axum::response::Result { - handle_http_route_impl(worker_ctx, name_or_identity, None, original_uri, request).await + handle_http_route_impl(worker_ctx, name_or_identity, "".to_string(), original_uri, request).await } pub async fn handle_http_route_root_slash( @@ -245,7 +245,7 @@ pub async fn handle_http_route_root_slash axum::response::Result { - handle_http_route_impl(worker_ctx, name_or_identity, Some(String::new()), original_uri, request).await + handle_http_route_impl(worker_ctx, name_or_identity, "/".to_string(), original_uri, request).await } pub async fn handle_http_route( @@ -254,7 +254,7 @@ pub async fn handle_http_route( OriginalUri(original_uri): OriginalUri, request: Request, ) -> axum::response::Result { - handle_http_route_impl(worker_ctx, name_or_identity, Some(path), original_uri, request).await + handle_http_route_impl(worker_ctx, name_or_identity, format!("/{path}"), original_uri, request).await } /// Error response body for unknown user-defined HTTP route. @@ -263,16 +263,10 @@ const NO_SUCH_ROUTE: &str = "Database has not registered a handler for this rout async fn handle_http_route_impl( worker_ctx: S, name_or_identity: NameOrIdentity, - path: Option, + handler_path: String, original_uri: http::Uri, request: Request, ) -> axum::response::Result { - let handler_path = match path.as_deref() { - None => "".to_string(), - Some("") => "/".to_string(), - Some(path) => format!("/{path}"), - }; - let (parts, body) = request.into_parts(); let st_method = http_method_to_st(&parts.method); From 4a4da89e497891a327eed2e03b784a0eace49738 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 24 Apr 2026 12:35:03 -0400 Subject: [PATCH 21/47] Move `HandlerContext` to `spacetimedb::http::HandlerContext`. --- crates/bindings/src/http.rs | 119 +++++++++++++++++- crates/bindings/src/lib.rs | 116 ----------------- crates/bindings/src/rng.rs | 4 +- crates/bindings/src/rt.rs | 5 +- .../pass/http_handler_no_style_warnings.rs | 4 +- .../tests/pass/table_index_name_conflict.rs | 4 +- .../tests/pass/table_name_name_conflict.rs | 4 +- crates/bindings/tests/ui/http_handlers.rs | 4 +- crates/bindings/tests/ui/http_handlers.stderr | 6 +- .../tests/smoketests/http_routes.rs | 16 +-- 10 files changed, 140 insertions(+), 142 deletions(-) diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index 488f76e4660..649998a2f27 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -7,14 +7,17 @@ use crate::{ rt::{read_bytes_source_as, read_bytes_source_into}, - IterBuf, + IterBuf, ReducerContext, StdbRng, Timestamp, TxContext, }; use bytes::Bytes; +#[cfg(feature = "rand")] +use rand08::RngCore; use spacetimedb_lib::db::raw_def::v10::MethodOrAny; use spacetimedb_lib::http::{ self as st_http, character_is_acceptable_for_route_path, ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION, }; -use spacetimedb_lib::{bsatn, TimeDuration}; +use spacetimedb_lib::{bsatn, Identity, TimeDuration, Uuid}; +use std::cell::{Cell, OnceCell}; use std::str::FromStr; pub type Request = http::Request; @@ -23,6 +26,118 @@ pub type Response = http::Response; pub use spacetimedb_bindings_macro::{http_handler as handler, http_router as router}; +/// The context that any HTTP handler is provided with. +/// +/// Each HTTP handler must accept `&mut spacetimedb::http::HandlerContext` as its first argument. +/// +/// Includes the time of invocation and exposes methods for running transactions +/// and performing side-effecting operations. +#[non_exhaustive] +pub struct HandlerContext { + /// The time at which the handler was started. + pub timestamp: Timestamp, + + /// Methods for performing HTTP requests. + pub http: HttpClient, + + #[cfg(feature = "rand08")] + pub(crate) rng: OnceCell, + + /// A counter used for generating UUIDv7 values. + /// **Note:** must be 0..=u32::MAX + #[cfg(feature = "rand")] + pub(crate) counter_uuid: Cell, +} + +impl HandlerContext { + pub(crate) fn new(timestamp: Timestamp) -> Self { + Self { + timestamp, + http: HttpClient {}, + #[cfg(feature = "rand08")] + rng: OnceCell::new(), + #[cfg(feature = "rand")] + counter_uuid: Cell::new(0), + } + } + + /// Read the current module's [`Identity`]. + pub fn identity(&self) -> Identity { + Identity::from_byte_array(spacetimedb_bindings_sys::identity()) + } + + /// Acquire a mutable transaction and execute `body` with read-write access. + pub fn with_tx(&mut self, body: impl Fn(&TxContext) -> T) -> T { + use core::convert::Infallible; + match self.try_with_tx::(|tx| Ok(body(tx))) { + Ok(v) => v, + Err(e) => match e {}, + } + } + + /// Acquire a mutable transaction and execute `body` with read-write access. + pub fn try_with_tx(&mut self, body: impl Fn(&TxContext) -> Result) -> Result { + let abort = || { + crate::sys::procedure::procedure_abort_mut_tx() + .expect("should have a pending mutable anon tx as `procedure_start_mut_tx` preceded") + }; + + let run = || { + let timestamp = crate::sys::procedure::procedure_start_mut_tx() + .expect("holding `&mut HandlerContext`, so should not be in a tx already; called manually elsewhere?"); + let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp); + + // Use the internal auth context (no external caller identity). + let tx = ReducerContext::new(crate::Local {}, Identity::ZERO, None, timestamp); + let tx = TxContext(tx); + + struct DoOnDrop(F); + impl Drop for DoOnDrop { + fn drop(&mut self) { + (self.0)(); + } + } + let abort_guard = DoOnDrop(abort); + let res = body(&tx); + core::mem::forget(abort_guard); + res + }; + + let mut res = run(); + + match res { + Ok(_) if crate::sys::procedure::procedure_commit_mut_tx().is_err() => { + log::warn!("committing anonymous transaction failed"); + res = run(); + match res { + Ok(_) => crate::sys::procedure::procedure_commit_mut_tx().expect("transaction retry failed again"), + Err(_) => abort(), + } + } + Ok(_) => {} + Err(_) => abort(), + } + + res + } + + /// Create a new random [`Uuid`] `v4` using the built-in RNG. + #[cfg(feature = "rand")] + pub fn new_uuid_v4(&self) -> anyhow::Result { + let mut bytes = [0u8; 16]; + self.rng().try_fill_bytes(&mut bytes)?; + Ok(Uuid::from_random_bytes_v4(bytes)) + } + + /// Create a new sortable [`Uuid`] `v7` using the built-in RNG, counter and timestamp. + #[cfg(feature = "rand")] + pub fn new_uuid_v7(&self) -> anyhow::Result { + let mut random_bytes = [0u8; 4]; + self.rng().try_fill_bytes(&mut random_bytes)?; + Uuid::from_counter_v7(&self.counter_uuid, self.timestamp, &random_bytes) + } +} + /// Describes an HTTP handler function for use with [`Router`]. /// /// The [`handler`] macro will define a constant of type [`Handler`], diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index ae453b1ad12..9e02a3a97f0 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -1444,122 +1444,6 @@ impl ProcedureContext { } } -/// The context that any HTTP handler is provided with. -/// -/// Each HTTP handler must accept `&mut HandlerContext` as its first argument. -/// -/// Includes the time of invocation and exposes methods for running transactions -/// and performing side-effecting operations. -#[non_exhaustive] -#[cfg(feature = "unstable")] -pub struct HandlerContext { - /// The time at which the handler was started. - pub timestamp: Timestamp, - - /// Methods for performing HTTP requests. - pub http: crate::http::HttpClient, - - #[cfg(feature = "rand08")] - rng: std::cell::OnceCell, - - /// A counter used for generating UUIDv7 values. - /// **Note:** must be 0..=u32::MAX - #[cfg(feature = "rand")] - counter_uuid: Cell, -} - -#[cfg(feature = "unstable")] -impl HandlerContext { - fn new(timestamp: Timestamp) -> Self { - Self { - timestamp, - http: http::HttpClient {}, - #[cfg(feature = "rand08")] - rng: std::cell::OnceCell::new(), - #[cfg(feature = "rand")] - counter_uuid: Cell::new(0), - } - } - - /// Read the current module's [`Identity`]. - pub fn identity(&self) -> Identity { - Identity::from_byte_array(spacetimedb_bindings_sys::identity()) - } - - /// Acquire a mutable transaction and execute `body` with read-write access. - #[cfg(feature = "unstable")] - pub fn with_tx(&mut self, body: impl Fn(&TxContext) -> T) -> T { - use core::convert::Infallible; - match self.try_with_tx::(|tx| Ok(body(tx))) { - Ok(v) => v, - Err(e) => match e {}, - } - } - - /// Acquire a mutable transaction and execute `body` with read-write access. - #[cfg(feature = "unstable")] - pub fn try_with_tx(&mut self, body: impl Fn(&TxContext) -> Result) -> Result { - let abort = || { - sys::procedure::procedure_abort_mut_tx() - .expect("should have a pending mutable anon tx as `procedure_start_mut_tx` preceded") - }; - - let run = || { - let timestamp = sys::procedure::procedure_start_mut_tx() - .expect("holding `&mut HandlerContext`, so should not be in a tx already; called manually elsewhere?"); - let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp); - - // Use the internal auth context (no external caller identity). - let tx = ReducerContext::new(Local {}, Identity::ZERO, None, timestamp); - let tx = TxContext(tx); - - struct DoOnDrop(F); - impl Drop for DoOnDrop { - fn drop(&mut self) { - (self.0)(); - } - } - let abort_guard = DoOnDrop(abort); - let res = body(&tx); - core::mem::forget(abort_guard); - res - }; - - let mut res = run(); - - match res { - Ok(_) if sys::procedure::procedure_commit_mut_tx().is_err() => { - log::warn!("committing anonymous transaction failed"); - res = run(); - match res { - Ok(_) => sys::procedure::procedure_commit_mut_tx().expect("transaction retry failed again"), - Err(_) => abort(), - } - } - Ok(_) => {} - Err(_) => abort(), - } - - res - } - - /// Create a new random [`Uuid`] `v4` using the built-in RNG. - #[cfg(all(feature = "unstable", feature = "rand"))] - pub fn new_uuid_v4(&self) -> anyhow::Result { - let mut bytes = [0u8; 16]; - self.rng().try_fill_bytes(&mut bytes)?; - Ok(Uuid::from_random_bytes_v4(bytes)) - } - - /// Create a new sortable [`Uuid`] `v7` using the built-in RNG, counter and timestamp. - #[cfg(all(feature = "unstable", feature = "rand"))] - pub fn new_uuid_v7(&self) -> anyhow::Result { - let mut random_bytes = [0u8; 4]; - self.rng().try_fill_bytes(&mut random_bytes)?; - Uuid::from_counter_v7(&self.counter_uuid, self.timestamp, &random_bytes) - } -} - /// A handle on a database with a particular table schema. pub trait DbContext { /// A view into the tables of a database. diff --git a/crates/bindings/src/rng.rs b/crates/bindings/src/rng.rs index bbb1ec6d04b..d9ed0bf65d7 100644 --- a/crates/bindings/src/rng.rs +++ b/crates/bindings/src/rng.rs @@ -1,6 +1,6 @@ -use crate::{rand, ReducerContext}; #[cfg(feature = "unstable")] -use crate::{HandlerContext, ProcedureContext}; +use crate::{http::HandlerContext, ProcedureContext}; +use crate::{rand, ReducerContext}; use core::cell::UnsafeCell; use core::marker::PhantomData; use rand::distributions::{Distribution, Standard}; diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index f1cda7f62ee..a1d196e3ce7 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -22,7 +22,10 @@ use std::sync::{Mutex, OnceLock}; pub use sys::raw::{BytesSink, BytesSource}; #[cfg(feature = "unstable")] -use crate::{http, HandlerContext, ProcedureContext, ProcedureResult}; +use crate::{ + http::{self, HandlerContext}, + ProcedureContext, ProcedureResult, +}; pub trait IntoVec { fn into_vec(self) -> Vec; diff --git a/crates/bindings/tests/pass/http_handler_no_style_warnings.rs b/crates/bindings/tests/pass/http_handler_no_style_warnings.rs index 1e49bfc9c2a..8b0f5f470cc 100644 --- a/crates/bindings/tests/pass/http_handler_no_style_warnings.rs +++ b/crates/bindings/tests/pass/http_handler_no_style_warnings.rs @@ -1,9 +1,9 @@ #![deny(warnings)] -use spacetimedb::http::{Request, Response}; +use spacetimedb::http::{HandlerContext, Request, Response}; #[spacetimedb::http::handler] -fn lowercase_handler(_ctx: &mut spacetimedb::HandlerContext, _req: Request) -> Response { +fn lowercase_handler(_ctx: &mut HandlerContext, _req: Request) -> Response { Response::new(().into()) } diff --git a/crates/bindings/tests/pass/table_index_name_conflict.rs b/crates/bindings/tests/pass/table_index_name_conflict.rs index 421e2729e53..b4edfa0f87b 100644 --- a/crates/bindings/tests/pass/table_index_name_conflict.rs +++ b/crates/bindings/tests/pass/table_index_name_conflict.rs @@ -4,10 +4,10 @@ // which were not in the `__` reserved namespace and had common names, // resulting in name collisions with user code. -use spacetimedb::http::{Request, Response}; +use spacetimedb::http::{HandlerContext, Request, Response}; #[spacetimedb::http::handler] -fn index(_ctx: &mut spacetimedb::HandlerContext, _req: Request) -> Response { +fn index(_ctx: &mut HandlerContext, _req: Request) -> Response { Response::new(().into()) } diff --git a/crates/bindings/tests/pass/table_name_name_conflict.rs b/crates/bindings/tests/pass/table_name_name_conflict.rs index 1019b3e62c0..be6ee711699 100644 --- a/crates/bindings/tests/pass/table_name_name_conflict.rs +++ b/crates/bindings/tests/pass/table_name_name_conflict.rs @@ -4,10 +4,10 @@ // which were not in the `__` reserved namespace and had common names, // resulting in name collisions with user code. -use spacetimedb::http::{Request, Response}; +use spacetimedb::http::{HandlerContext, Request, Response}; #[spacetimedb::http::handler] -fn name(_ctx: &mut spacetimedb::HandlerContext, _req: Request) -> Response { +fn name(_ctx: &mut HandlerContext, _req: Request) -> Response { Response::new(().into()) } diff --git a/crates/bindings/tests/ui/http_handlers.rs b/crates/bindings/tests/ui/http_handlers.rs index 5d75ac58293..d05cd9d8114 100644 --- a/crates/bindings/tests/ui/http_handlers.rs +++ b/crates/bindings/tests/ui/http_handlers.rs @@ -1,5 +1,5 @@ -use spacetimedb::http::{handler, router, Request, Response, Router}; -use spacetimedb::{table, HandlerContext, ProcedureContext, Table}; +use spacetimedb::http::{handler, router, HandlerContext, Request, Response, Router}; +use spacetimedb::{table, ProcedureContext, Table}; #[handler] fn handler_no_args() -> Response { diff --git a/crates/bindings/tests/ui/http_handlers.stderr b/crates/bindings/tests/ui/http_handlers.stderr index a3faf805171..960b3cdeae9 100644 --- a/crates/bindings/tests/ui/http_handlers.stderr +++ b/crates/bindings/tests/ui/http_handlers.stderr @@ -29,10 +29,10 @@ error: HTTP router functions must take no arguments | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `Router` - --> tests/ui/http_handlers.rs:1:61 + --> tests/ui/http_handlers.rs:1:77 | -1 | use spacetimedb::http::{handler, router, Request, Response, Router}; - | ^^^^^^ +1 | use spacetimedb::http::{handler, router, HandlerContext, Request, Response, Router}; + | ^^^^^^ | = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index eca288a5d71..11f2f902ed3 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -1,8 +1,7 @@ use spacetimedb_smoketests::Smoketest; const MODULE_CODE: &str = r#" -use spacetimedb::http::{Body, Request, Response, Router}; -use spacetimedb::HandlerContext; +use spacetimedb::http::{Body, HandlerContext, Request, Response, Router}; use spacetimedb::Table; #[spacetimedb::table(accessor = entries, public)] @@ -87,8 +86,8 @@ fn router() -> Router { const EXAMPLE_MODULE_CODE: &str = r#" use std::str::FromStr; -use spacetimedb::http::{Body, Request, Response, Router}; -use spacetimedb::{HandlerContext, Table}; +use spacetimedb::http::{Body, HandlerContext, Request, Response, Router}; +use spacetimedb::Table; #[spacetimedb::table(accessor = data)] struct Data { @@ -128,8 +127,7 @@ fn router() -> Router { "#; const STRICT_ROOT_ROUTING_MODULE_CODE: &str = r#" -use spacetimedb::http::{Body, Request, Response, Router}; -use spacetimedb::HandlerContext; +use spacetimedb::http::{Body, HandlerContext, Request, Response, Router}; #[spacetimedb::http::handler] fn empty_root(_ctx: &mut HandlerContext, _req: Request) -> Response { @@ -162,8 +160,7 @@ fn router() -> Router { "#; const STRICT_NON_ROOT_ROUTING_MODULE_CODE: &str = r#" -use spacetimedb::http::{Body, Request, Response, Router}; -use spacetimedb::HandlerContext; +use spacetimedb::http::{Body, HandlerContext, Request, Response, Router}; #[spacetimedb::http::handler] fn foo(_ctx: &mut HandlerContext, _req: Request) -> Response { @@ -184,8 +181,7 @@ fn router() -> Router { "#; const FULL_URI_MODULE_CODE: &str = r#" -use spacetimedb::http::{Body, Request, Response, Router}; -use spacetimedb::HandlerContext; +use spacetimedb::http::{Body, HandlerContext, Request, Response, Router}; #[spacetimedb::http::handler] fn echo_uri(_ctx: &mut HandlerContext, req: Request) -> Response { From 86e6b6f0a194a29806ba08aee97f7c9b13eccde6 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 24 Apr 2026 13:52:18 -0400 Subject: [PATCH 22/47] Add docs for HTTP handlers and router --- .../00200-functions/00400-procedures.md | 2 +- .../00200-reference/00200-http-api/00300-database.md | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/docs/00200-core-concepts/00200-functions/00400-procedures.md b/docs/docs/00200-core-concepts/00200-functions/00400-procedures.md index 97cab79bb07..3905920ece0 100644 --- a/docs/docs/00200-core-concepts/00200-functions/00400-procedures.md +++ b/docs/docs/00200-core-concepts/00200-functions/00400-procedures.md @@ -72,7 +72,7 @@ Because procedures are unstable, Rust modules that define them must opt in to th ```toml [dependencies] -spacetimedb = { version = "1.*", features = ["unstable"] } +spacetimedb = { version = "2.*", features = ["unstable"] } ``` Define a procedure by annotating a function with `#[spacetimedb::procedure]`. diff --git a/docs/docs/00300-resources/00200-reference/00200-http-api/00300-database.md b/docs/docs/00300-resources/00200-reference/00200-http-api/00300-database.md index 2b59274b7da..04bfe181e61 100644 --- a/docs/docs/00300-resources/00200-reference/00200-http-api/00300-database.md +++ b/docs/docs/00300-resources/00200-reference/00200-http-api/00300-database.md @@ -9,7 +9,7 @@ The HTTP endpoints in `/v1/database` allow clients to interact with Spacetime da ## At a glance | Route | Description | -| -------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +|----------------------------------------------------------------------------------------------------|---------------------------------------------------| | [`POST /v1/database`](#post-v1database) | Publish a new database given its module code. | | [`PUT /v1/database/:name_or_identity`](#put-v1databasename_or_identity) | Publish to a database given its module code. | | [`GET /v1/database/:name_or_identity`](#get-v1databasename_or_identity) | Get a JSON description of a database. | @@ -23,6 +23,7 @@ The HTTP endpoints in `/v1/database` allow clients to interact with Spacetime da | [`GET /v1/database/:name_or_identity/schema`](#get-v1databasename_or_identityschema) | Get the schema for a database. | | [`GET /v1/database/:name_or_identity/logs`](#get-v1databasename_or_identitylogs) | Retrieve logs from a database. | | [`POST /v1/database/:name_or_identity/sql`](#post-v1databasename_or_identitysql) | Run a SQL query against a database. | +| [`ANY /v1/database/:name_or_identity/route/{*path}`](#any-v1databasename_or_identityroutepath) | Access database-defined HTTP APIs. | ## `POST /v1/database` @@ -473,3 +474,7 @@ Returns a JSON array of statement results, each of which takes the form: The `schema` will be a [JSON-encoded `ProductType`](../00300-internals/00200-sats-json.md) describing the type of the returned rows. The `rows` will be an array of [JSON-encoded `ProductValue`s](../00300-internals/00200-sats-json.md), each of which conforms to the `schema`. + +## `ANY /v1/database/:name_or_identity/route/{*path}` + +Access routes defined by a database using [HTTP handlers](../../../00200-core-concepts/00200-functions/000600-HTTP-handlers.md). From b5e6e1ac399cc244b541e0287078be7c95d380da Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 24 Apr 2026 13:52:35 -0400 Subject: [PATCH 23/47] Add smoketest that examples in docs work --- .../tests/smoketests/http_routes.rs | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 11f2f902ed3..dd98bf3b1c5 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -1,4 +1,6 @@ -use spacetimedb_smoketests::Smoketest; +use regex::Regex; +use spacetimedb_smoketests::{workspace_root, Smoketest}; +use std::{fs, path::Path}; const MODULE_CODE: &str = r#" use spacetimedb::http::{Body, HandlerContext, Request, Response, Router}; @@ -196,6 +198,25 @@ fn router() -> Router { const NO_SUCH_ROUTE_BODY: &str = "Database has not registered a handler for this route"; +fn extract_rust_code_blocks(doc_path: &Path) -> String { + let doc = fs::read_to_string(doc_path).unwrap_or_else(|e| panic!("failed to read {}: {e}", doc_path.display())); + let doc = doc.replace("\r\n", "\n"); + + let re = Regex::new(r"```rust\n([\s\S]*?)\n```").expect("regex should compile"); + let blocks: Vec<_> = re + .captures_iter(&doc) + .map(|cap| cap.get(1).expect("capture group should exist").as_str().to_string()) + .collect(); + + assert!( + !blocks.is_empty(), + "expected at least one rust code block in {}", + doc_path.display() + ); + + blocks.join("\n\n") +} + #[test] fn http_routes_end_to_end() { let test = Smoketest::builder().module_code(MODULE_CODE).build(); @@ -375,3 +396,20 @@ fn http_handler_observes_full_external_uri() { assert!(resp.status().is_success()); assert_eq!(resp.text().expect("echo-uri body"), url); } + +/// Validates the Rust example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. +#[test] +fn http_handlers_tutorial_say_hello_route_works() { + let module_code = extract_rust_code_blocks( + &workspace_root().join("docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md"), + ); + let test = Smoketest::builder().module_code(&module_code).build(); + let identity = test.database_identity.as_ref().expect("database identity missing"); + + let url = format!("{}/v1/database/{}/route/say-hello", test.server_url, identity); + let client = reqwest::blocking::Client::new(); + + let resp = client.get(&url).send().expect("say-hello failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("say-hello body"), "Hello!"); +} From 6c691ae248f8cfe5cefa5ec8511cbb6535bdc260 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 24 Apr 2026 15:52:07 -0400 Subject: [PATCH 24/47] Axum 0.7 syntax for routes --- crates/client-api/src/routes/database.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 508331a1ece..66f6aff5318 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -1488,7 +1488,7 @@ where ); let authed_named_router = axum::Router::new() - .nest("/{name_or_identity}", db_router) + .nest("/:name_or_identity", db_router) .route_layer(axum::middleware::from_fn_with_state(ctx, anon_auth_middleware::)); // NOTE: HTTP route handlers are intentionally unauthenticated so they can accept @@ -1497,9 +1497,9 @@ where // Authorization headers do not trigger early rejection or attach SpacetimeAuth. // Keep these routes merged separately from the authenticated database router. let http_route_router = axum::Router::::new() - .route("/{name_or_identity}/route", any(handle_http_route_root::)) - .route("/{name_or_identity}/route/", any(handle_http_route_root_slash::)) - .route("/{name_or_identity}/route/{*path}", any(handle_http_route::)); + .route("/:name_or_identity/route", any(handle_http_route_root::)) + .route("/:name_or_identity/route/", any(handle_http_route_root_slash::)) + .route("/:name_or_identity/route/*path", any(handle_http_route::)); axum::Router::new() .merge(authed_root_router) From df07ba9f70efcd72cfd7b42f272b88c1b187d84b Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 24 Apr 2026 16:30:02 -0400 Subject: [PATCH 25/47] Add forgotten file --- .../00200-functions/00600-HTTP-handlers.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md diff --git a/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md b/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md new file mode 100644 index 00000000000..67589fad0ba --- /dev/null +++ b/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md @@ -0,0 +1,83 @@ +--- +title: HTTP Handlers +slug: /functions/http-handlers +--- + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' + +HTTP handlers allow a SpacetimeDB database to expose an HTTP API. +External clients can make HTTP requests to routes nested under [`/v1/database/:name_or_address/route`](../../00300-resources/00200-reference/00200-http-api/00300-database.md#any-v1databasename_or_identityroutepath); these requests are resolved to routes defined by the database and then passed to the corresponding HTTP handler. + +:::warning +***HTTP handlers are currently in beta, and their API may change in upcoming SpacetimeDB releases.*** +::: + +## Defining HTTP Handlers + + + + +Because HTTP handlers are unstable, Rust modules that define them must opt in to the `unstable` feature in their `Cargo.toml`: + +```toml +[dependencies] +spacetimedb = { version = "2.*", features = ["unstable"] } +``` + +Define an HTTP handler by annotating a function with `#[spacetimedb::http::handler]`. + +The function must accept exactly two arguments: + +1. A `&mut spacetimedb::http::HandlerContext`. +2. A `spacetimedb::http::Request`. + +The function must return a `spacetimedb::http::Response`. + +```rust +use spacetimedb::http::{Body, handler, HandlerContext, Request, Response}; + +#[handler] +fn say_hello(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::new(Body::from_bytes("Hello!")) +} +``` + + + + +## Registering Handlers to Routes + +Once you've [defined an HTTP handler](#defining-http-handlers), you must register it to a route in order to make it reachable for requests. + + + + +All routes exposed by your module are declared in a `spacetimedb::http::Router`. Register the `Router` for your database by returning it from a function annotated with `#[spacetimedb::http::router]`. + +```rust +use spacetimedb::http::{router, Router}; + +#[router] +fn router() -> Router { + Router::new() + .get("/say-hello", say_hello) +} +``` + +Add routes within a router with the `get`, `head`, `options`, `put`, `delete`, `post`, `patch` and `any` methods, which register an HTTP handler for that HTTP method at a given path. + +Nest routers with `router.nest(prefix, sub_router)`, which causes `sub_router` to handle routing for all paths that start with `prefix`. + +Combine routers with `router.merge(other_router)`, which combines both routers. + + + + +### Strict Routing + +SpacetimeDB uses strict routing, meaning that a request must match a path exactly in order to be routed to that handler. Trailing slashes are significant. + +## Sending Requests + +Routes defined by a SpacetimeDB database are exposed under the prefix `/v1/database/:name/route`. To access the `say-hello` route above, send a request to `$SPACETIMEDB_URI/v1/database/$DATABASE/route/say-hello`, where `$SPACETIMEDB_URI` is the SpacetimeDB host (usually `https://maincloud.spacetimedb.com`), and `$DATABASE` is the name of the database. From 163f6af760cde58aabf43fc02d6a3937fb6a1510 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 24 Apr 2026 16:47:29 -0400 Subject: [PATCH 26/47] Correct (?) link --- .../00200-reference/00200-http-api/00300-database.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/00300-resources/00200-reference/00200-http-api/00300-database.md b/docs/docs/00300-resources/00200-reference/00200-http-api/00300-database.md index 04bfe181e61..0392b485e6d 100644 --- a/docs/docs/00300-resources/00200-reference/00200-http-api/00300-database.md +++ b/docs/docs/00300-resources/00200-reference/00200-http-api/00300-database.md @@ -477,4 +477,4 @@ The `rows` will be an array of [JSON-encoded `ProductValue`s](../00300-internals ## `ANY /v1/database/:name_or_identity/route/{*path}` -Access routes defined by a database using [HTTP handlers](../../../00200-core-concepts/00200-functions/000600-HTTP-handlers.md). +Access routes defined by a database using [HTTP handlers](../../../00200-core-concepts/00200-functions/00600-HTTP-handlers.md). From d296a17b2d867e05f0c1d6763a52670a9d50550d Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 28 Apr 2026 12:56:16 -0400 Subject: [PATCH 27/47] Add a smoketest that inspects the request body --- .../tests/smoketests/http_routes.rs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index dd98bf3b1c5..40abde11f8e 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -196,6 +196,40 @@ fn router() -> Router { } "#; +const HANDLE_REQUEST_BODY_MODULE_CODE: &str = r#" +use spacetimedb::http::{Body, HandlerContext, Request, Response, Router}; + +#[spacetimedb::http::handler] +fn reverse_bytes(_ctx: &mut HandlerContext, req: Request) -> Response { + let mut reversed = req.into_body().into_bytes().to_vec(); + reversed.reverse(); + Response::new(Body::from_bytes(reversed)) +} + +#[spacetimedb::http::handler] +fn reverse_words(_ctx: &mut HandlerContext, req: Request) -> Response { + let body = match req.into_body().into_string() { + Ok(body) => body, + Err(_) => { + return Response::builder() + .status(400) + .body(Body::from_bytes("request body must be valid UTF-8")) + .expect("response builder should not fail"); + } + }; + + let reversed = body.split(' ').rev().collect::>().join(" "); + Response::new(Body::from_bytes(reversed)) +} + +#[spacetimedb::http::router] +fn router() -> Router { + Router::new() + .post("/reverse-bytes", reverse_bytes) + .post("/reverse-words", reverse_words) +} +"#; + const NO_SUCH_ROUTE_BODY: &str = "Database has not registered a handler for this route"; fn extract_rust_code_blocks(doc_path: &Path) -> String { @@ -397,6 +431,77 @@ fn http_handler_observes_full_external_uri() { assert_eq!(resp.text().expect("echo-uri body"), url); } +#[test] +fn handle_request_body() { + let test = Smoketest::builder() + .module_code(HANDLE_REQUEST_BODY_MODULE_CODE) + .build(); + let identity = test.database_identity.as_ref().expect("database identity missing"); + + let base = format!("{}/v1/database/{}/route", test.server_url, identity); + let client = reqwest::blocking::Client::new(); + + let resp = client + .post(format!("{base}/reverse-bytes")) + .body(vec![0xFF, 0x00, 0xFE, 0x7F]) + .send() + .expect("reverse-bytes invalid utf-8 failed"); + assert!(resp.status().is_success()); + assert_eq!( + resp.bytes().expect("reverse-bytes invalid utf-8 body").as_ref(), + [0x7F, 0xFE, 0x00, 0xFF] + ); + + let resp = client + .post(format!("{base}/reverse-bytes")) + .body("abcba") + .send() + .expect("reverse-bytes palindrome failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.bytes().expect("reverse-bytes palindrome body").as_ref(), b"abcba"); + + let resp = client + .post(format!("{base}/reverse-bytes")) + .body("stressed") + .send() + .expect("reverse-bytes non-palindrome failed"); + assert!(resp.status().is_success()); + assert_eq!( + resp.bytes().expect("reverse-bytes non-palindrome body").as_ref(), + b"desserts" + ); + + let resp = client + .post(format!("{base}/reverse-words")) + .body(vec![0x66, 0x6F, 0x80, 0x6F]) + .send() + .expect("reverse-words invalid utf-8 failed"); + assert_eq!(resp.status().as_u16(), 400); + assert_eq!( + resp.text().expect("reverse-words invalid utf-8 body"), + "request body must be valid UTF-8" + ); + + let resp = client + .post(format!("{base}/reverse-words")) + .body("step on no pets") + .send() + .expect("reverse-words palindrome failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("reverse-words palindrome body"), "pets no on step"); + + let resp = client + .post(format!("{base}/reverse-words")) + .body("red green blue") + .send() + .expect("reverse-words non-palindrome failed"); + assert!(resp.status().is_success()); + assert_eq!( + resp.text().expect("reverse-words non-palindrome body"), + "blue green red" + ); +} + /// Validates the Rust example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. #[test] fn http_handlers_tutorial_say_hello_route_works() { From 5f8e5446e5b5c39a8372f6044c084085cebd4fac Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Thu, 30 Apr 2026 10:25:12 -0400 Subject: [PATCH 28/47] Expand comment re: unstable feature in CI --- tools/ci/src/main.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 172299ff353..9e7893702a7 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -472,7 +472,9 @@ fn main() -> Result<()> { "unreal" ) .run()?; - // Bindings snapshot tests rely on the unstable feature. + // Bindings snapshot tests rely on the unstable feature, + // as they compile and test APIs which are gated behind that feature, + // e.g. procedures, HTTP handlers. cmd!( "cargo", "test", From e832d2ee9d078a37e1bf9d1cd6ab0555016b4901 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Thu, 30 Apr 2026 12:15:38 -0400 Subject: [PATCH 29/47] Separate request/response body from metadata at ABI level To allow future body streaming. --- crates/bindings/src/http.rs | 66 +++++++++++++++++-- crates/bindings/src/rt.rs | 49 ++++++++++++-- crates/client-api/src/routes/database.rs | 29 ++++---- crates/core/src/host/module_host.rs | 11 ++-- .../src/host/wasm_common/module_host_actor.rs | 12 ++-- .../src/host/wasmtime/wasm_instance_env.rs | 41 +++++++++++- .../core/src/host/wasmtime/wasmtime_module.rs | 37 +++++++++-- crates/lib/src/http.rs | 17 ----- 8 files changed, 200 insertions(+), 62 deletions(-) diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index 649998a2f27..6212b2e7b9d 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -542,8 +542,7 @@ fn convert_response(response: st_http::Response) -> http::Result http::Request { - let st_http::RequestAndBody { request, body } = req; +pub(crate) fn request_from_wire(request: st_http::Request, body: Bytes) -> http::Request { let st_http::Request { method, headers, @@ -593,7 +592,7 @@ pub(crate) fn request_from_wire(req: st_http::RequestAndBody) -> http::Request) -> st_http::ResponseAndBody { +pub(crate) fn response_into_wire(response: http::Response) -> (st_http::Response, Bytes) { let (parts, body) = response.into_parts(); let st_response = st_http::Response { headers: parts @@ -612,10 +611,9 @@ pub(crate) fn response_into_wire(response: http::Response) -> st_http::Res code: parts.status.as_u16(), }; - st_http::ResponseAndBody { - response: st_response, - body: body.into_bytes(), - } + // TODO(streaming-http): stop collecting the whole response body here once handler + // responses can write incrementally to a body sink. + (st_response, body.into_bytes()) } /// Represents the body of an HTTP request or response. @@ -749,3 +747,57 @@ impl From for Error { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_from_wire_preserves_metadata_and_body() { + let request = st_http::Request { + method: st_http::Method::Post, + headers: vec![ + (Some("content-type".into()), b"application/octet-stream".as_slice().into()), + (Some("x-echo".into()), b"value".as_slice().into()), + ] + .into_iter() + .collect(), + timeout: None, + uri: "https://example.invalid/upload?x=1".to_string(), + version: st_http::Version::Http2, + }; + + let request = request_from_wire(request, Bytes::from_static(b"payload")); + + assert_eq!(request.method(), http::Method::POST); + assert_eq!(request.version(), http::Version::HTTP_2); + assert_eq!(request.uri(), &http::Uri::from_static("https://example.invalid/upload?x=1")); + assert_eq!(request.headers()["content-type"], "application/octet-stream"); + assert_eq!(request.headers()["x-echo"], "value"); + assert_eq!(request.into_body().into_bytes(), Bytes::from_static(b"payload")); + } + + #[test] + fn response_into_wire_splits_metadata_and_body() { + let response = http::Response::builder() + .status(201) + .version(http::Version::HTTP_11) + .header("content-type", "text/plain") + .header("x-result", "ok") + .body(Body::from_bytes("created")) + .expect("response builder should not fail"); + + let (response_meta, response_body) = response_into_wire(response); + + assert_eq!(response_meta.code, 201); + assert!(matches!(response_meta.version, st_http::Version::Http11)); + + let headers = response_meta.headers.into_iter().collect::>(); + assert_eq!(headers.len(), 2); + assert_eq!(headers[0].0.as_ref(), "content-type"); + assert_eq!(&headers[0].1[..], b"text/plain"); + assert_eq!(headers[1].0.as_ref(), "x-result"); + assert_eq!(&headers[1].1[..], b"ok"); + assert_eq!(response_body, Bytes::from_static(b"created")); + } +} diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index a1d196e3ce7..79543f4a925 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -1179,21 +1179,56 @@ extern "C" fn __call_procedure__( 0 } -/// Called by the host to execute an HTTP handler. +/// Called by the host to execute the HTTP handler identified by `id` +/// in response to the HTTP request `(request, request_body)`. +/// +/// The `timestamp` will be the time as of the handler's invocation, +/// encoded appropriately for conversion to a `spacetimedb_lib::Timestamp`, +/// i.e. as microseconds since the Unix epoch. +/// +/// The `request` will contain a BSATN-encoded `spacetimedb_lib::http::Request` +/// with the metadata of the request, including URI, method, headers &c. +/// +/// The `request_body` will contain the raw bytes of the request body. +/// If the request included an empty HTTP body, then `request_body` will be [`BytesSource::INVALID`]. +/// +/// The HTTP handler should write a BSATN-encoded `spacetimedb_lib::http::Response` to `response_sink` +/// containing the response metdata, including status, headers &c. +/// +/// The HTTP handler should also write the raw bytes of its HTTP response body to the `response_body_sink`. +/// +/// HTTP handlers always return the errno 0. All other return values are reserved. #[cfg(feature = "unstable")] #[unsafe(no_mangle)] -extern "C" fn __call_http_handler__(id: usize, timestamp: u64, request: BytesSource, result_sink: BytesSink) -> i16 { +extern "C" fn __call_http_handler__( + id: usize, + timestamp: u64, + request: BytesSource, + request_body: BytesSource, + response_sink: BytesSink, + response_body_sink: BytesSink, +) -> i16 { let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp as i64); let mut ctx = HandlerContext::new(timestamp); let handlers = HTTP_HANDLERS.get().unwrap(); - let request = read_bytes_source_as::(request); - let request = http::request_from_wire(request); + let request = read_bytes_source_as::(request); + // TODO(streaming-http): stop reading the full request body into guest memory once handlers + // can consume the body incrementally from the host-provided byte source. + let request_body = if request_body == BytesSource::INVALID { + bytes::Bytes::new() + } else { + let mut buf = IterBuf::take(); + read_bytes_source_into(request_body, &mut buf); + buf.clone().into() + }; + let request = http::request_from_wire(request, request_body); let response = handlers[id](&mut ctx, request); - let response = http::response_into_wire(response); - let bytes = bsatn::to_vec(&response).expect("failed to serialize http response"); - write_to_sink(result_sink, &bytes); + let (response_meta, response_body_bytes) = http::response_into_wire(response); + let bytes = bsatn::to_vec(&response_meta).expect("failed to serialize http response"); + write_to_sink(response_sink, &bytes); + write_to_sink(response_body_sink, &response_body_bytes); 0 } diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 66f6aff5318..f462102c588 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -277,20 +277,19 @@ async fn handle_http_route_impl( return Ok((StatusCode::NOT_FOUND, NO_SUCH_ROUTE).into_response()); }; + // TODO(streaming-http): stop collecting the full request body here once route dispatch can + // hand Axum's body stream through the WASM handler ABI incrementally. let body = body.collect().await.map_err(log_and_500)?.to_bytes(); let forwarded_uri = reconstruct_external_uri(&original_uri, &parts.headers); - let request = st_http::RequestAndBody { - request: st_http::Request { - method: st_method.clone(), - headers: headers_to_st(parts.headers), - timeout: None, - uri: forwarded_uri, - version: http_version_to_st(parts.version), - }, - body, + let request = st_http::Request { + method: st_method.clone(), + headers: headers_to_st(parts.headers), + timeout: None, + uri: forwarded_uri, + version: http_version_to_st(parts.version), }; - let response = match module.call_http_handler(handler_id, request).await { + let response = match module.call_http_handler(handler_id, request, body).await { Ok(response) => response, Err(spacetimedb::host::module_host::HttpHandlerCallError::NoSuchHandler) => { return Ok((StatusCode::NOT_FOUND, NO_SUCH_ROUTE).into_response()); @@ -311,7 +310,7 @@ async fn handle_http_route_impl( } }; - let response = response_from_st(response)?; + let response = response_from_st(response.0, response.1)?; Ok(response.into_response()) } @@ -401,10 +400,14 @@ fn headers_to_st(headers: http::HeaderMap) -> st_http::Headers { .collect() } -fn response_from_st(response: st_http::ResponseAndBody) -> axum::response::Result> { - let st_http::ResponseAndBody { response, body } = response; +fn response_from_st( + response: st_http::Response, + body: Bytes, +) -> axum::response::Result> { let st_http::Response { headers, version, code } = response; + // TODO(streaming-http): stop materializing the whole response body before building the Axum + // response once the handler ABI can stream directly into the outbound HTTP body. let mut response = http::Response::new(Body::from(body)); *response.version_mut() = match version { st_http::Version::Http09 => http::Version::HTTP_09, diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 1ae2372df41..81c6dd20623 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -53,7 +53,7 @@ use spacetimedb_execution::pipelined::{PipelinedProject, ViewProject}; use spacetimedb_execution::RelValue; use spacetimedb_expr::expr::CollectViews; use spacetimedb_lib::db::raw_def::v9::Lifecycle; -use spacetimedb_lib::http::{RequestAndBody, ResponseAndBody}; +use spacetimedb_lib::http::{Request as HttpRequest, Response as HttpResponse}; use spacetimedb_lib::identity::{AuthCtx, RequestId}; use spacetimedb_lib::metrics::ExecutionMetrics; use spacetimedb_lib::{ConnectionId, Timestamp}; @@ -747,7 +747,8 @@ impl CallProcedureParams { pub struct CallHttpHandlerParams { pub timestamp: Timestamp, pub handler_id: HttpHandlerId, - pub request: RequestAndBody, + pub request: HttpRequest, + pub request_body: Bytes, } /// Holds a [`Module`] and a set of [`Instance`]s from it, @@ -1948,8 +1949,9 @@ impl ModuleHost { pub async fn call_http_handler( &self, handler_id: HttpHandlerId, - request: RequestAndBody, - ) -> Result { + request: HttpRequest, + request_body: Bytes, + ) -> Result<(HttpResponse, Bytes), HttpHandlerCallError> { if self.info.module_def.get_http_handler_by_id(handler_id).is_none() { return Err(HttpHandlerCallError::NoSuchHandler); } @@ -1958,6 +1960,7 @@ impl ModuleHost { timestamp: Timestamp::now(), handler_id, request, + request_body, }; self.call_pooled( diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index e723f4b3791..f4b1aff69bf 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -318,7 +318,7 @@ pub type ViewExecuteResult = ExecutionResult; pub type ProcedureExecuteResult = ExecutionResult; -pub type HttpHandlerExecuteResult = ExecutionResult; +pub type HttpHandlerExecuteResult = ExecutionResult<(Bytes, Bytes), anyhow::Error>; pub struct WasmModuleHostActor { module: T::InstancePre, @@ -538,7 +538,7 @@ impl WasmModuleInstance { pub async fn call_http_handler( &mut self, params: CallHttpHandlerParams, - ) -> Result { + ) -> Result<(st_http::Response, Bytes), HttpHandlerCallError> { let (res, trapped) = self.common.call_http_handler(params, &mut self.instance).await; self.trapped = trapped; res @@ -807,11 +807,12 @@ impl InstanceCommon { &mut self, params: CallHttpHandlerParams, inst: &mut I, - ) -> (Result, bool) { + ) -> (Result<(st_http::Response, Bytes), HttpHandlerCallError>, bool) { let CallHttpHandlerParams { timestamp, handler_id, request, + request_body, } = params; let Some(handler_def) = self.info.module_def.get_http_handler_by_id(handler_id) else { @@ -836,6 +837,7 @@ impl InstanceCommon { name: handler_name.clone(), timestamp, request_bytes, + request_body_bytes: request_body, }; let energy_fingerprint = FunctionFingerprint { @@ -875,7 +877,8 @@ impl InstanceCommon { .inc(); Err(HttpHandlerCallError::InternalError(format!("{err}"))) } - Ok(return_val) => bsatn::from_slice::(&return_val[..]) + Ok((response_bytes, response_body)) => bsatn::from_slice::(&response_bytes[..]) + .map(|response| (response, response_body)) .map_err(|err| HttpHandlerCallError::InternalError(format!("{err}"))), }; @@ -1841,6 +1844,7 @@ pub struct HttpHandlerOp { pub name: Identifier, pub timestamp: Timestamp, pub request_bytes: Bytes, + pub request_body_bytes: Bytes, } impl InstanceOp for HttpHandlerOp { diff --git a/crates/core/src/host/wasmtime/wasm_instance_env.rs b/crates/core/src/host/wasmtime/wasm_instance_env.rs index 7c0b2a088b2..08273b54bb2 100644 --- a/crates/core/src/host/wasmtime/wasm_instance_env.rs +++ b/crates/core/src/host/wasmtime/wasm_instance_env.rs @@ -118,6 +118,12 @@ pub(super) struct WasmInstanceEnv { /// The standard sink used for [`Self::bytes_sink_write`]. standard_bytes_sink: Option>, + /// Additional sinks used by ABIs that need more than one writable byte channel. + bytes_sinks: IntMap>, + + /// Counter as a source of extra bytes sink IDs. + next_bytes_sink_id: NonZeroU32, + /// The slab of `BufferIters` created for this instance. iters: RowIters, @@ -154,6 +160,8 @@ impl WasmInstanceEnv { bytes_sources: IntMap::default(), next_bytes_source_id: NonZeroU32::new(1).unwrap(), standard_bytes_sink: None, + bytes_sinks: IntMap::default(), + next_bytes_sink_id: NonZeroU32::new(STANDARD_BYTES_SINK + 1).unwrap(), iters: Default::default(), timing_spans: Default::default(), call_times: CallTimes::new(), @@ -195,6 +203,10 @@ impl WasmInstanceEnv { } } + pub fn create_extra_bytes_source(&mut self, bytes: bytes::Bytes) -> RtResult { + self.create_bytes_source(bytes) + } + fn free_bytes_source(&mut self, id: BytesSourceId) { if self.bytes_sources.remove(&id).is_none() { log::warn!("`free_bytes_source` on non-existent source {id:?}"); @@ -244,6 +256,20 @@ impl WasmInstanceEnv { self.standard_bytes_sink.take().unwrap_or_default() } + pub fn create_extra_bytes_sink(&mut self) -> u32 { + let id = self.next_bytes_sink_id.get(); + self.next_bytes_sink_id = self + .next_bytes_sink_id + .checked_add(1) + .expect("allocating next `BytesSink` overflowed `u32`"); + self.bytes_sinks.insert(id, Vec::new()); + id + } + + pub fn take_extra_bytes_sink(&mut self, sink: u32) -> Vec { + self.bytes_sinks.remove(&sink).unwrap_or_default() + } + /// Signal to this `WasmInstanceEnv` that a reducer or procedure call is beginning. /// /// Returns the handle used by reducers and procedures to read from `args` @@ -303,6 +329,8 @@ impl WasmInstanceEnv { // so that we don't leak either the IDs or the buffers themselves. self.bytes_sources = IntMap::default(); self.next_bytes_source_id = NonZeroU32::new(1).unwrap(); + self.bytes_sinks = IntMap::default(); + self.next_bytes_sink_id = NonZeroU32::new(STANDARD_BYTES_SINK + 1).unwrap(); (timings, self.take_standard_bytes_sink()) } @@ -1376,9 +1404,16 @@ impl WasmInstanceEnv { Self::cvt_custom(caller, AbiCall::BytesSinkWrite, |caller| { let (mem, env) = Self::mem_env(caller); - // Retrieve the reducer args if available and requested, or error. - let Some(sink) = env.standard_bytes_sink.as_mut().filter(|_| sink == STANDARD_BYTES_SINK) else { - return Ok(errno::NO_SUCH_BYTES.get().into()); + let sink = if sink == STANDARD_BYTES_SINK { + let Some(sink) = env.standard_bytes_sink.as_mut() else { + return Ok(errno::NO_SUCH_BYTES.get().into()); + }; + sink + } else { + let Some(sink) = env.bytes_sinks.get_mut(&sink) else { + return Ok(errno::NO_SUCH_BYTES.get().into()); + }; + sink }; // Read `buffer_len`, i.e., the capacity of `buffer` pointed to by `buffer_ptr`. diff --git a/crates/core/src/host/wasmtime/wasmtime_module.rs b/crates/core/src/host/wasmtime/wasmtime_module.rs index b73f39a9f3e..d967b99b388 100644 --- a/crates/core/src/host/wasmtime/wasmtime_module.rs +++ b/crates/core/src/host/wasmtime/wasmtime_module.rs @@ -424,9 +424,13 @@ pub(super) type CallHttpHandlerType = TypedFunc< u32, // timestamp u64, - // byte source id for request + // byte source id for request metadata u32, - // byte sink id for response + // byte source id for request body + u32, + // byte sink id for response metadata + u32, + // byte sink id for response body u32, ), i32, @@ -677,10 +681,15 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { prepare_store_for_call(store, budget); let call_type = op.call_type(); - let (request_source, result_sink) = + let (request_source, response_sink) = store .data_mut() .start_funcall(op.name.clone(), op.request_bytes, op.timestamp, call_type); + let request_body_source = store + .data_mut() + .create_extra_bytes_source(op.request_body_bytes) + .expect("failed to register http handler request body"); + let response_body_sink = store.data_mut().create_extra_bytes_sink(); let Some(call_http_handler) = self.call_http_handler.as_ref() else { let res = module_host_actor::HttpHandlerExecuteResult { @@ -701,21 +710,25 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { op.id.0, op.timestamp.to_micros_since_unix_epoch() as u64, request_source.0, - result_sink, + request_body_source.0, + response_sink, + response_body_sink, ), ) .await; store.data_mut().terminate_dangling_anon_tx(); - let (stats, result_bytes) = finish_opcall(store, budget); + let (stats, result_bytes, response_body_bytes) = finish_http_handler_opcall(store, budget, response_body_sink); let call_result = call_result.and_then(|code| { - (code == 0).then_some(result_bytes.into()).ok_or_else(|| { + (code == 0) + .then_some((result_bytes.into(), response_body_bytes.into())) + .ok_or_else(|| { anyhow::anyhow!( "{CALL_HTTP_HANDLER_DUNDER} returned unexpected code {code}. HTTP handlers should return code 0 or trap." ) - }) + }) }); let res = module_host_actor::HttpHandlerExecuteResult { stats, call_result }; @@ -794,6 +807,16 @@ fn finish_opcall(store: &mut Store, initial_budget: FunctionBud (stats, ret_bytes) } +fn finish_http_handler_opcall( + store: &mut Store, + initial_budget: FunctionBudget, + response_body_sink: u32, +) -> (ExecutionStats, Vec, Vec) { + let response_body_bytes = store.data_mut().take_extra_bytes_sink(response_body_sink); + let (stats, response_bytes) = finish_opcall(store, initial_budget); + (stats, response_bytes, response_body_bytes) +} + fn zero_execution_stats(store: &Store) -> ExecutionStats { ExecutionStats { energy: module_host_actor::EnergyStats::ZERO, diff --git a/crates/lib/src/http.rs b/crates/lib/src/http.rs index 41ef49fbd63..137c3a9c306 100644 --- a/crates/lib/src/http.rs +++ b/crates/lib/src/http.rs @@ -18,7 +18,6 @@ //! 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 bytes::Bytes; use spacetimedb_sats::{time_duration::TimeDuration, SpacetimeType}; /// Represents an HTTP request which can be made from a procedure running in a SpacetimeDB database. @@ -167,22 +166,6 @@ impl Response { } } -/// An HTTP request plus a body, used for host <-> module interchange in HTTP handlers. -#[derive(Clone, SpacetimeType)] -#[sats(crate = crate, name = "HttpRequestAndBody")] -pub struct RequestAndBody { - pub request: Request, - pub body: Bytes, -} - -/// An HTTP response plus a body, used for host <-> module interchange in HTTP handlers. -#[derive(Clone, SpacetimeType)] -#[sats(crate = crate, name = "HttpResponseAndBody")] -pub struct ResponseAndBody { - pub response: Response, - pub body: Bytes, -} - /// True if `c` is a valid character to appear in the path of a user-defined HTTP route. /// /// We permit only lowercase ASCII letters, ASCII digits, and `-_~/`. From 7c54b94f2dfc11637954e14e52bf51493b16f845 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Thu, 30 Apr 2026 12:53:39 -0400 Subject: [PATCH 30/47] Unify the "standard" and "extra" bytes sinks My LLM had decided to make an additive-only change to maintain the pre-existing "standard" bytes sink separately in addition to its new selection of "extra" bytes sinks, which was silly. This commit changes to instead have one big set of bytes sinks, with no special handling for bytes sink 1. This is similar to our treatment of bytes sources. --- .../src/host/wasmtime/wasm_instance_env.rs | 81 +++++++------------ .../core/src/host/wasmtime/wasmtime_module.rs | 34 ++++---- 2 files changed, 47 insertions(+), 68 deletions(-) diff --git a/crates/core/src/host/wasmtime/wasm_instance_env.rs b/crates/core/src/host/wasmtime/wasm_instance_env.rs index 08273b54bb2..663a0fecfa8 100644 --- a/crates/core/src/host/wasmtime/wasm_instance_env.rs +++ b/crates/core/src/host/wasmtime/wasm_instance_env.rs @@ -115,13 +115,10 @@ pub(super) struct WasmInstanceEnv { /// Recall that zero is [`BytesSourceId::INVALID`], so we have to start at 1. next_bytes_source_id: NonZeroU32, - /// The standard sink used for [`Self::bytes_sink_write`]. - standard_bytes_sink: Option>, - - /// Additional sinks used by ABIs that need more than one writable byte channel. + /// `File`-like byte sinks which guest code can write via [`Self::bytes_sink_write`]. bytes_sinks: IntMap>, - /// Counter as a source of extra bytes sink IDs. + /// Counter as a source of sink IDs. next_bytes_sink_id: NonZeroU32, /// The slab of `BufferIters` created for this instance. @@ -141,8 +138,6 @@ pub(super) struct WasmInstanceEnv { chunk_pool: ChunkPool, } -const STANDARD_BYTES_SINK: u32 = 1; - type WasmResult = Result; type RtResult = anyhow::Result; @@ -159,9 +154,8 @@ impl WasmInstanceEnv { mem: None, bytes_sources: IntMap::default(), next_bytes_source_id: NonZeroU32::new(1).unwrap(), - standard_bytes_sink: None, bytes_sinks: IntMap::default(), - next_bytes_sink_id: NonZeroU32::new(STANDARD_BYTES_SINK + 1).unwrap(), + next_bytes_sink_id: NonZeroU32::new(1).unwrap(), iters: Default::default(), timing_spans: Default::default(), call_times: CallTimes::new(), @@ -177,6 +171,15 @@ impl WasmInstanceEnv { Ok(BytesSourceId(id.into())) } + fn alloc_bytes_sink_id(&mut self) -> u32 { + let id = self.next_bytes_sink_id.get(); + self.next_bytes_sink_id = self + .next_bytes_sink_id + .checked_add(1) + .expect("allocating next `BytesSink` overflowed `u32`"); + id + } + /// Binds `bytes` to the environment and assigns it an ID. /// /// If `bytes` is empty, `BytesSourceId::INVALID` is returned. @@ -244,29 +247,13 @@ impl WasmInstanceEnv { &self.instance_env } - /// Setup the standard bytes sink and return a handle to it for writing. - pub fn setup_standard_bytes_sink(&mut self) -> u32 { - self.standard_bytes_sink = Some(Vec::new()); - STANDARD_BYTES_SINK - } - - /// Extract all the bytes written to the standard bytes sink - /// and prevent further writes to it. - pub fn take_standard_bytes_sink(&mut self) -> Vec { - self.standard_bytes_sink.take().unwrap_or_default() - } - - pub fn create_extra_bytes_sink(&mut self) -> u32 { - let id = self.next_bytes_sink_id.get(); - self.next_bytes_sink_id = self - .next_bytes_sink_id - .checked_add(1) - .expect("allocating next `BytesSink` overflowed `u32`"); + pub fn create_bytes_sink(&mut self) -> u32 { + let id = self.alloc_bytes_sink_id(); self.bytes_sinks.insert(id, Vec::new()); id } - pub fn take_extra_bytes_sink(&mut self, sink: u32) -> Vec { + pub fn take_bytes_sink(&mut self, sink: u32) -> Vec { self.bytes_sinks.remove(&sink).unwrap_or_default() } @@ -284,13 +271,13 @@ impl WasmInstanceEnv { // Create the output sink. // Reducers which fail will write their error message here. // Procedures will write their result here. - let errors = self.setup_standard_bytes_sink(); + let result_sink = self.create_bytes_sink(); let args = self.create_bytes_source(args).unwrap(); self.instance_env.start_funcall(name, ts, func_type); - (args, errors) + (args, result_sink) } /// Returns the name of the most recent reducer or procedure to be run in this environment, @@ -310,7 +297,7 @@ impl WasmInstanceEnv { /// and the errors written by the WASM code to the standard error sink. /// /// This resets the call times and clears the arguments source and error sink. - pub fn finish_funcall(&mut self) -> (ExecutionTimings, Vec) { + pub fn finish_funcall(&mut self, result_sink: u32) -> (ExecutionTimings, Vec) { // For the moment, // we only explicitly clear the source/sink buffers and the "syscall" times. // TODO: should we be clearing `iters` and/or `timing_spans`? @@ -329,10 +316,11 @@ impl WasmInstanceEnv { // so that we don't leak either the IDs or the buffers themselves. self.bytes_sources = IntMap::default(); self.next_bytes_source_id = NonZeroU32::new(1).unwrap(); + let result_bytes = self.take_bytes_sink(result_sink); self.bytes_sinks = IntMap::default(); - self.next_bytes_sink_id = NonZeroU32::new(STANDARD_BYTES_SINK + 1).unwrap(); + self.next_bytes_sink_id = NonZeroU32::new(1).unwrap(); - (timings, self.take_standard_bytes_sink()) + (timings, result_bytes) } /// After a procedure has finished, take its known last tx offset, if any. @@ -1404,16 +1392,8 @@ impl WasmInstanceEnv { Self::cvt_custom(caller, AbiCall::BytesSinkWrite, |caller| { let (mem, env) = Self::mem_env(caller); - let sink = if sink == STANDARD_BYTES_SINK { - let Some(sink) = env.standard_bytes_sink.as_mut() else { - return Ok(errno::NO_SUCH_BYTES.get().into()); - }; - sink - } else { - let Some(sink) = env.bytes_sinks.get_mut(&sink) else { - return Ok(errno::NO_SUCH_BYTES.get().into()); - }; - sink + let Some(sink) = env.bytes_sinks.get_mut(&sink) else { + return Ok(errno::NO_SUCH_BYTES.get().into()); }; // Read `buffer_len`, i.e., the capacity of `buffer` pointed to by `buffer_ptr`. @@ -1787,24 +1767,20 @@ impl WasmInstanceEnv { view_call: &ViewCallInfo, view_name: &Identifier, ) -> anyhow::Result { - // Preserve the procedure's result/error sink so this view does not overwrite it. - let previous_standard_sink = { - let env = caller.data_mut(); - env.standard_bytes_sink.take() - }; - let prev_func_type = caller .data_mut() .instance_env .swap_func_type(FuncCallType::View(view_call.clone())); + let mut nested_result_sink = None; let call_result = (|| -> anyhow::Result { let (args_source, result_sink) = { let env = caller.data_mut(); let args_source = env.create_bytes_source(bytes::Bytes::new())?; - let result_sink = env.setup_standard_bytes_sink(); + let result_sink = env.create_bytes_sink(); (args_source, result_sink) }; + nested_result_sink = Some(result_sink); let (call_view, call_view_anon) = { let env = caller.data(); @@ -1829,10 +1805,7 @@ impl WasmInstanceEnv { let result_bytes = { let env = caller.data_mut(); - // Restore the outer sink of the procedure before propagating any trap/user error from the call. - let result = env.take_standard_bytes_sink(); - env.standard_bytes_sink = previous_standard_sink; - result + env.take_bytes_sink(nested_result_sink.expect("nested view result sink missing")) }; let code = call_result?; diff --git a/crates/core/src/host/wasmtime/wasmtime_module.rs b/crates/core/src/host/wasmtime/wasmtime_module.rs index d967b99b388..9c87e433da1 100644 --- a/crates/core/src/host/wasmtime/wasmtime_module.rs +++ b/crates/core/src/host/wasmtime/wasmtime_module.rs @@ -242,9 +242,9 @@ impl module_host_actor::WasmInstancePre for WasmtimeModule { } if let Ok(init) = instance.get_typed_func::(&mut store, SETUP_DUNDER) { - let setup_error = store.data_mut().setup_standard_bytes_sink(); + let setup_error = store.data_mut().create_bytes_sink(); let res = call_sync_typed_func(&init, &mut store, setup_error); - let error = store.data_mut().take_standard_bytes_sink(); + let error = store.data_mut().take_bytes_sink(setup_error); let res = res .map_err(ExecutionError::Trap) @@ -455,14 +455,14 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { .get_typed_func::(&mut self.store, describer_func_name) .map_err(DescribeError::Signature)?; - let sink = self.store.data_mut().setup_standard_bytes_sink(); + let sink = self.store.data_mut().create_bytes_sink(); run_describer(log_traceback, || { call_sync_typed_func(&describer, &mut self.store, sink) })?; // Fetch the bsatn returned by the describer call. - let bytes = self.store.data_mut().take_standard_bytes_sink(); + let bytes = self.store.data_mut().take_bytes_sink(sink); let desc: RawModuleDef = bsatn::from_slice(&bytes).map_err(DescribeError::Decode)?; @@ -517,7 +517,7 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { ), ); - let (stats, error) = finish_opcall(store, budget); + let (stats, error) = finish_opcall(store, budget, errors_sink); let call_result = call_result .map_err(ExecutionError::Trap) @@ -550,7 +550,7 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { errors_sink, ); - let (stats, result_bytes) = finish_opcall(store, budget); + let (stats, result_bytes) = finish_opcall(store, budget, errors_sink); let call_result = call_result .map_err(ExecutionError::Trap) @@ -586,7 +586,7 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { errors_sink, ); - let (stats, result_bytes) = finish_opcall(store, budget); + let (stats, result_bytes) = finish_opcall(store, budget, errors_sink); let call_result = call_result .map_err(ExecutionError::Trap) @@ -652,7 +652,7 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { store.data_mut().terminate_dangling_anon_tx(); // Close the timing span for this procedure and get the BSATN bytes of its result. - let (stats, result_bytes) = finish_opcall(store, budget); + let (stats, result_bytes) = finish_opcall(store, budget, result_sink); let call_result = call_result.and_then(|code| { (code == 0).then_some(result_bytes.into()).ok_or_else(|| { @@ -689,7 +689,7 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { .data_mut() .create_extra_bytes_source(op.request_body_bytes) .expect("failed to register http handler request body"); - let response_body_sink = store.data_mut().create_extra_bytes_sink(); + let response_body_sink = store.data_mut().create_bytes_sink(); let Some(call_http_handler) = self.call_http_handler.as_ref() else { let res = module_host_actor::HttpHandlerExecuteResult { @@ -719,7 +719,8 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { store.data_mut().terminate_dangling_anon_tx(); - let (stats, result_bytes, response_body_bytes) = finish_http_handler_opcall(store, budget, response_body_sink); + let (stats, result_bytes, response_body_bytes) = + finish_http_handler_opcall(store, budget, response_sink, response_body_sink); let call_result = call_result.and_then(|code| { (code == 0) @@ -786,11 +787,15 @@ fn prepare_connection_id_for_call(caller_connection_id: ConnectionId) -> [u64; 2 } /// Finish the op call and calculate its [`ExecutionStats`]. -fn finish_opcall(store: &mut Store, initial_budget: FunctionBudget) -> (ExecutionStats, Vec) { +fn finish_opcall( + store: &mut Store, + initial_budget: FunctionBudget, + result_sink: u32, +) -> (ExecutionStats, Vec) { // Signal that this call is finished. This gets us the timings // associated with it, and clears all of the instance state // related to it. - let (timings, ret_bytes) = store.data_mut().finish_funcall(); + let (timings, ret_bytes) = store.data_mut().finish_funcall(result_sink); let remaining_fuel = get_store_fuel(store); let remaining: FunctionBudget = remaining_fuel.into(); @@ -810,10 +815,11 @@ fn finish_opcall(store: &mut Store, initial_budget: FunctionBud fn finish_http_handler_opcall( store: &mut Store, initial_budget: FunctionBudget, + response_sink: u32, response_body_sink: u32, ) -> (ExecutionStats, Vec, Vec) { - let response_body_bytes = store.data_mut().take_extra_bytes_sink(response_body_sink); - let (stats, response_bytes) = finish_opcall(store, initial_budget); + let response_body_bytes = store.data_mut().take_bytes_sink(response_body_sink); + let (stats, response_bytes) = finish_opcall(store, initial_budget, response_sink); (stats, response_bytes, response_body_bytes) } From af880fc49e8c690f7e1d439bc5efcadf492bf926 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 1 May 2026 12:03:24 -0400 Subject: [PATCH 31/47] fmt --- crates/bindings/src/http.rs | 10 ++++++++-- crates/client-api/src/routes/database.rs | 5 +---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index 6212b2e7b9d..a6115601607 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -757,7 +757,10 @@ mod tests { let request = st_http::Request { method: st_http::Method::Post, headers: vec![ - (Some("content-type".into()), b"application/octet-stream".as_slice().into()), + ( + Some("content-type".into()), + b"application/octet-stream".as_slice().into(), + ), (Some("x-echo".into()), b"value".as_slice().into()), ] .into_iter() @@ -771,7 +774,10 @@ mod tests { assert_eq!(request.method(), http::Method::POST); assert_eq!(request.version(), http::Version::HTTP_2); - assert_eq!(request.uri(), &http::Uri::from_static("https://example.invalid/upload?x=1")); + assert_eq!( + request.uri(), + &http::Uri::from_static("https://example.invalid/upload?x=1") + ); assert_eq!(request.headers()["content-type"], "application/octet-stream"); assert_eq!(request.headers()["x-echo"], "value"); assert_eq!(request.into_body().into_bytes(), Bytes::from_static(b"payload")); diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index f462102c588..288b4c11d5a 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -400,10 +400,7 @@ fn headers_to_st(headers: http::HeaderMap) -> st_http::Headers { .collect() } -fn response_from_st( - response: st_http::Response, - body: Bytes, -) -> axum::response::Result> { +fn response_from_st(response: st_http::Response, body: Bytes) -> axum::response::Result> { let st_http::Response { headers, version, code } = response; // TODO(streaming-http): stop materializing the whole response body before building the Axum From 5999b64f51e2a91c4b72faedb693e25850b3ef85 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 1 May 2026 12:11:37 -0400 Subject: [PATCH 32/47] Add doc comment to `Router`, amend doc comments on route register methods --- crates/bindings/src/http.rs | 46 +++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index a6115601607..0e0f2683d73 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -163,6 +163,34 @@ impl Handler { } } +/// A collection of routes bound to HTTP handlers. +/// +/// Define HTTP handlers with the [`handler`] macro. +/// +/// Bind handlers to paths with: +/// - [`Self::get`] +/// - [`Self::head`] +/// - [`Self::put`] +/// - [`Self::options`] +/// - [`Self::put`] +/// - [`Self::delete`] +/// - [`Self::post`] +/// - [`Self::patch`] +/// - [`Self::any`] +/// +/// ## Paths +/// +/// Each route binds a handler to an HTTP method at a path. +/// +/// The empty string `""` is a valid path, which refers to the root route. +/// +/// All other paths must start with a slash `/`. +/// +/// Only characters described by [`spacetimedb_lib::http::ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION`] +/// are valid for use in paths. This set may be expanded in the future. +/// +/// SpacetimeDB uses strict routing, meaning that trailing slashes `/` in paths are significant. +/// `/foo` and `/foo/` are distinct paths. #[derive(Clone, Default)] pub struct Router { routes: Vec, @@ -187,7 +215,7 @@ impl Router { /// including one registered with [`Self::any`], /// or if this path overlaps with a nested router registered by [`Self::nest`]. /// - /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + /// Panics if the `path` is [invalid](Self#paths). pub fn get(self, path: impl Into, handler: Handler) -> Self { self.add_route(MethodOrAny::Method(st_http::Method::Get), path, handler) } @@ -198,7 +226,7 @@ impl Router { /// including one registered with [`Self::any`], /// or if this path overlaps with a nested router registered by [`Self::nest`]. /// - /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + /// Panics if the `path` is [invalid](Self#paths). pub fn head(self, path: impl Into, handler: Handler) -> Self { self.add_route(MethodOrAny::Method(st_http::Method::Head), path, handler) } @@ -209,7 +237,7 @@ impl Router { /// including one registered with [`Self::any`], /// or if this path overlaps with a nested router registered by [`Self::nest`]. /// - /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + /// Panics if the `path` is [invalid](Self#paths). pub fn options(self, path: impl Into, handler: Handler) -> Self { self.add_route(MethodOrAny::Method(st_http::Method::Options), path, handler) } @@ -220,7 +248,7 @@ impl Router { /// including one registered with [`Self::any`], /// or if this path overlaps with a nested router registered by [`Self::nest`]. /// - /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + /// Panics if the `path` is [invalid](Self#paths). pub fn put(self, path: impl Into, handler: Handler) -> Self { self.add_route(MethodOrAny::Method(st_http::Method::Put), path, handler) } @@ -231,7 +259,7 @@ impl Router { /// including one registered with [`Self::any`], /// or if this path overlaps with a nested router registered by [`Self::nest`]. /// - /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + /// Panics if the `path` is [invalid](Self#paths). pub fn delete(self, path: impl Into, handler: Handler) -> Self { self.add_route(MethodOrAny::Method(st_http::Method::Delete), path, handler) } @@ -242,7 +270,7 @@ impl Router { /// including one registered with [`Self::any`], /// or if this path overlaps with a nested router registered by [`Self::nest`]. /// - /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + /// Panics if the `path` is [invalid](Self#paths). pub fn post(self, path: impl Into, handler: Handler) -> Self { self.add_route(MethodOrAny::Method(st_http::Method::Post), path, handler) } @@ -253,7 +281,7 @@ impl Router { /// including one registered with [`Self::any`], /// or if this path overlaps with a nested router registered by [`Self::nest`]. /// - /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + /// Panics if the `path` is [invalid](Self#paths). pub fn patch(self, path: impl Into, handler: Handler) -> Self { self.add_route(MethodOrAny::Method(st_http::Method::Patch), path, handler) } @@ -263,7 +291,7 @@ impl Router { /// Panics if `self` already has a handler on at least one method at this path, /// or if this path overlaps with a nested router registered by [`Self::nest`]. /// - /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + /// Panics if the `path` is [invalid](Self#paths). pub fn any(self, path: impl Into, handler: Handler) -> Self { self.add_route(MethodOrAny::Any, path, handler) } @@ -274,7 +302,7 @@ impl Router { /// /// Panics if `self` already has any handlers registered on paths which start with `path`. /// - /// Panics if the `path` does not begin with `/`, or if it contains any characters which are not URL-safe. + /// Panics if the `path` is [invalid](Self#paths). pub fn nest(self, path: impl Into, sub_router: Self) -> Self { let path = path.into(); assert_valid_path(&path); From 5fab87743c4e0d3f38cdc2b949113534b45ab1a6 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 1 May 2026 12:32:47 -0400 Subject: [PATCH 33/47] Add doc comments for `handler` and `router` macros --- crates/bindings/src/http.rs | 55 ++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index 0e0f2683d73..0ef3ea6cee0 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -24,7 +24,56 @@ pub type Request = http::Request; pub type Response = http::Response; -pub use spacetimedb_bindings_macro::{http_handler as handler, http_router as router}; +/// Define an HTTP handler, a special database function which handles HTTP requests. +/// +/// HTTP handlers must be functions of two arguments, [`&mut HandlerContext`](HandlerContext) and [`Request`], +/// and must return [`Response`]. +/// +/// ```no_run +/// # use spacetimedb::http::{handler, Request, Response, Body, HandlerContext}; +/// #[handler] +/// fn hello_world(_ctx: &mut HandlerContext, _req: Request) -> Response { +/// Response::new(Body::from_bytes("Hello, world!")) +/// } +/// ``` +/// +/// In order to be reachable, a handler must be registered in the database's [macro@router]. +/// +/// This macro will clobber the original function definition, making it no longer callable by name. +/// +/// ```compile_fail +/// # use spacetimedb::http::{handler, Request, Response, Body, HandlerContext}; +/// # #[handler] +/// # fn hello_world(_ctx: &mut HandlerContext, _req: Request) -> Response { +/// # Response::new(Body::from_bytes("Hello, world!")) +/// # } +/// # fn foo() { +/// # let ctx: HandlerContext = todo!(); +/// # let ctx: &mut HandlerContext = &mut ctx; +/// # let req: Request = todo!(); +/// hello_world(ctx, req); // Won't compile, as our handler `hello_world`'s function was clobbered. +/// # } +/// ``` +#[doc(inline)] +pub use spacetimedb_bindings_macro::http_handler as handler; + +/// Register a [`Router`](struct@Router) to route HTTP requests to handlers. +/// +/// This should annotate a function of no arguments which returns a [`Router`](struct@router). +/// +/// ```no_run +/// # use spacetimedb::http::{handler, router, Request, Response, Body, HandlerContext, Router}; +/// # #[handler] +/// # fn hello_world(_ctx: &mut HandlerContext, _req: Request) -> Response { +/// # Response::new(Body::from_bytes("Hello, world!")) +/// # } +/// #[router] +/// fn my_router() -> Router { +/// Router::new().get("/hello-world", hello_world) +/// } +/// ``` +#[doc(inline)] +pub use spacetimedb_bindings_macro::http_router as router; /// The context that any HTTP handler is provided with. /// @@ -191,6 +240,10 @@ impl Handler { /// /// SpacetimeDB uses strict routing, meaning that trailing slashes `/` in paths are significant. /// `/foo` and `/foo/` are distinct paths. +/// +/// ## Registering +/// +/// Register a `Handler` as the root handler of your database with the [`handler` macro](macro@handler). #[derive(Clone, Default)] pub struct Router { routes: Vec, From 5c233e6e84332de1c79dab8d8069d294ee2e2783 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 11 May 2026 12:01:06 -0400 Subject: [PATCH 34/47] Respond to Mazdak's review --- crates/bindings/src/http.rs | 57 ++---------------- crates/bindings/src/lib.rs | 117 ++++++++++++++++++------------------ 2 files changed, 63 insertions(+), 111 deletions(-) diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index 0ef3ea6cee0..a0ee89819ba 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -7,7 +7,7 @@ use crate::{ rt::{read_bytes_source_as, read_bytes_source_into}, - IterBuf, ReducerContext, StdbRng, Timestamp, TxContext, + try_with_tx, with_tx, IterBuf, StdbRng, Timestamp, TxContext, }; use bytes::Bytes; #[cfg(feature = "rand")] @@ -39,7 +39,7 @@ pub type Response = http::Response; /// /// In order to be reachable, a handler must be registered in the database's [macro@router]. /// -/// This macro will clobber the original function definition, making it no longer callable by name. +/// This macro will shadow the original function definition, making it no longer callable by name. /// /// ```compile_fail /// # use spacetimedb::http::{handler, Request, Response, Body, HandlerContext}; @@ -51,7 +51,7 @@ pub type Response = http::Response; /// # let ctx: HandlerContext = todo!(); /// # let ctx: &mut HandlerContext = &mut ctx; /// # let req: Request = todo!(); -/// hello_world(ctx, req); // Won't compile, as our handler `hello_world`'s function was clobbered. +/// hello_world(ctx, req); // Won't compile, as our handler `hello_world`'s function was shadowed. /// # } /// ``` #[doc(inline)] @@ -117,57 +117,12 @@ impl HandlerContext { /// Acquire a mutable transaction and execute `body` with read-write access. pub fn with_tx(&mut self, body: impl Fn(&TxContext) -> T) -> T { - use core::convert::Infallible; - match self.try_with_tx::(|tx| Ok(body(tx))) { - Ok(v) => v, - Err(e) => match e {}, - } + with_tx(body) } /// Acquire a mutable transaction and execute `body` with read-write access. pub fn try_with_tx(&mut self, body: impl Fn(&TxContext) -> Result) -> Result { - let abort = || { - crate::sys::procedure::procedure_abort_mut_tx() - .expect("should have a pending mutable anon tx as `procedure_start_mut_tx` preceded") - }; - - let run = || { - let timestamp = crate::sys::procedure::procedure_start_mut_tx() - .expect("holding `&mut HandlerContext`, so should not be in a tx already; called manually elsewhere?"); - let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp); - - // Use the internal auth context (no external caller identity). - let tx = ReducerContext::new(crate::Local {}, Identity::ZERO, None, timestamp); - let tx = TxContext(tx); - - struct DoOnDrop(F); - impl Drop for DoOnDrop { - fn drop(&mut self) { - (self.0)(); - } - } - let abort_guard = DoOnDrop(abort); - let res = body(&tx); - core::mem::forget(abort_guard); - res - }; - - let mut res = run(); - - match res { - Ok(_) if crate::sys::procedure::procedure_commit_mut_tx().is_err() => { - log::warn!("committing anonymous transaction failed"); - res = run(); - match res { - Ok(_) => crate::sys::procedure::procedure_commit_mut_tx().expect("transaction retry failed again"), - Err(_) => abort(), - } - } - Ok(_) => {} - Err(_) => abort(), - } - - res + try_with_tx(body) } /// Create a new random [`Uuid`] `v4` using the built-in RNG. @@ -259,7 +214,7 @@ pub(crate) struct RouteSpec { impl Router { /// Returns a new, empty `Router`. pub fn new() -> Self { - Self { routes: Vec::new() } + Self::default() } /// Registers `handler` to handle `GET` requests at `path`. diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 9e02a3a97f0..01a7ed14468 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -1175,6 +1175,61 @@ impl Deref for TxContext { } } +#[cfg(feature = "unstable")] +fn try_with_tx(body: impl Fn(&TxContext) -> Result) -> Result { + let abort = || { + crate::sys::procedure::procedure_abort_mut_tx() + .expect("should have a pending mutable anon tx as `procedure_start_mut_tx` preceded") + }; + + let run = || { + let timestamp = crate::sys::procedure::procedure_start_mut_tx() + .expect("holding `&mut HandlerContext`, so should not be in a tx already; called manually elsewhere?"); + let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp); + + // Use the internal auth context (no external caller identity). + let tx = ReducerContext::new(crate::Local {}, Identity::ZERO, None, timestamp); + let tx = TxContext(tx); + + struct DoOnDrop(F); + impl Drop for DoOnDrop { + fn drop(&mut self) { + (self.0)(); + } + } + let abort_guard = DoOnDrop(abort); + let res = body(&tx); + core::mem::forget(abort_guard); + res + }; + + let mut res = run(); + + match res { + Ok(_) if crate::sys::procedure::procedure_commit_mut_tx().is_err() => { + log::warn!("committing anonymous transaction failed"); + res = run(); + match res { + Ok(_) => crate::sys::procedure::procedure_commit_mut_tx().expect("transaction retry failed again"), + Err(_) => abort(), + } + } + Ok(_) => {} + Err(_) => abort(), + } + + res +} + +#[cfg(feature = "unstable")] +fn with_tx(body: impl Fn(&TxContext) -> T) -> T { + use core::convert::Infallible; + match try_with_tx::(|tx| Ok(body(tx))) { + Ok(v) => v, + Err(e) => match e {}, + } +} + /// The context that any procedure is provided with. /// /// Each procedure must accept `&mut ProcedureContext` as its first argument. @@ -1305,11 +1360,7 @@ impl ProcedureContext { /// This includes interior mutability through types like [`std::cell::Cell`]. #[cfg(feature = "unstable")] pub fn with_tx(&mut self, body: impl Fn(&TxContext) -> T) -> T { - use core::convert::Infallible; - match self.try_with_tx::(|tx| Ok(body(tx))) { - Ok(v) => v, - Err(e) => match e {}, - } + with_tx(body) } /// Acquire a mutable transaction @@ -1343,61 +1394,7 @@ impl ProcedureContext { /// This includes interior mutability through types like [`std::cell::Cell`]. #[cfg(feature = "unstable")] pub fn try_with_tx(&mut self, body: impl Fn(&TxContext) -> Result) -> Result { - let abort = || { - sys::procedure::procedure_abort_mut_tx() - .expect("should have a pending mutable anon tx as `procedure_start_mut_tx` preceded") - }; - - let run = || { - // Start the transaction. - - use core::mem; - let timestamp = sys::procedure::procedure_start_mut_tx().expect( - "holding `&mut ProcedureContext`, so should not be in a tx already; called manually elsewhere?", - ); - let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp); - - // We've resumed, so let's do the work, but first prepare the context. - let tx = ReducerContext::new(Local {}, self.sender, self.connection_id, timestamp); - let tx = TxContext(tx); - - // Guard the execution of `body` with a scope-guard that `abort`s on panic. - // Wasmtime now supports unwinding, so we need to protect against that. - // We're not using `scopeguard::guard` here to avoid an extra dependency. - struct DoOnDrop(F); - impl Drop for DoOnDrop { - fn drop(&mut self) { - (self.0)(); - } - } - let abort_guard = DoOnDrop(abort); - let res = body(&tx); - // Defuse the bomb. - mem::forget(abort_guard); - res - }; - - let mut res = run(); - - // Commit or roll back? - match res { - Ok(_) if sys::procedure::procedure_commit_mut_tx().is_err() => { - // Tried to commit, but couldn't. Retry once. - log::warn!("committing anonymous transaction failed"); - // NOTE(procedure,centril): there's no actual guarantee that `body` - // does the exact same as the time before, as the timestamps differ - // and due to interior mutability. - res = run(); - match res { - Ok(_) => sys::procedure::procedure_commit_mut_tx().expect("transaction retry failed again"), - Err(_) => abort(), - } - } - Ok(_) => {} - Err(_) => abort(), - } - - res + try_with_tx(body) } /// Create a new random [`Uuid`] `v4` using the built-in RNG. From b258f959e66ec1c11c35877817b34aba4d66e6a5 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 18 May 2026 10:41:37 -0400 Subject: [PATCH 35/47] fmt, clippy --- crates/lib/src/db/raw_def/v10.rs | 1 - crates/schema/src/def.rs | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/lib/src/db/raw_def/v10.rs b/crates/lib/src/db/raw_def/v10.rs index 9c1406e2ecd..64e82a00bb6 100644 --- a/crates/lib/src/db/raw_def/v10.rs +++ b/crates/lib/src/db/raw_def/v10.rs @@ -127,7 +127,6 @@ pub enum MethodOrAny { #[derive(Debug, Clone, SpacetimeType)] #[sats(crate = crate)] #[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] - pub struct RawModuleMountV10 { pub namespace: String, pub module: RawModuleDefV10, diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index 13be2b04787..3649e045af4 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -33,9 +33,9 @@ use spacetimedb_data_structures::map::{Equivalent, HashMap}; use spacetimedb_lib::db::raw_def; use spacetimedb_lib::db::raw_def::v10::{ ExplicitNames, MethodOrAny, RawConstraintDefV10, RawHttpHandlerDefV10, RawHttpRouteDefV10, RawIndexDefV10, - RawLifeCycleReducerDefV10, RawModuleDefV10, RawModuleDefV10Section, RawModuleMountV10, RawProcedureDefV10, RawReducerDefV10, - RawRowLevelSecurityDefV10, RawScheduleDefV10, RawScopedTypeNameV10, RawSequenceDefV10, RawTableDefV10, - RawTypeDefV10, RawViewDefV10, + RawLifeCycleReducerDefV10, RawModuleDefV10, RawModuleDefV10Section, RawModuleMountV10, RawProcedureDefV10, + RawReducerDefV10, RawRowLevelSecurityDefV10, RawScheduleDefV10, RawScopedTypeNameV10, RawSequenceDefV10, + RawTableDefV10, RawTypeDefV10, RawViewDefV10, }; use spacetimedb_lib::db::raw_def::v9::{ Lifecycle, RawColumnDefaultValueV9, RawConstraintDataV9, RawConstraintDefV9, RawIndexAlgorithm, RawIndexDefV9, From a7c02cc41d78e058e86661bd1b7d02f0cc559fed Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 19 May 2026 10:28:21 -0400 Subject: [PATCH 36/47] Put HTTP handler routes in `DatabaseRoutes` So that SpacetimeDB-cloud can apply proxy middleware. --- crates/client-api/src/routes/database.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 7d2966f4e8a..cf9e4b343c6 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -1506,6 +1506,12 @@ pub struct DatabaseRoutes { pub db_reset: MethodRouter, /// GET: /database/: name_or_identity/unstable/timestamp pub timestamp_get: MethodRouter, + /// ANY: /database/:name_or_identity/route + pub http_route_root: MethodRouter, + /// ANY: /database/:name_or_identity/route/ + pub http_route_root_slash: MethodRouter, + /// ANY: /database/:name_or_identity/route/*path + pub http_route: MethodRouter, } impl Default for DatabaseRoutes @@ -1513,7 +1519,7 @@ where S: NodeDelegate + ControlStateDelegate + HasWebSocketOptions + Authorization + Clone + 'static, { fn default() -> Self { - use axum::routing::{delete, get, post, put}; + use axum::routing::{any, delete, get, post, put}; Self { root_post: post(publish::), db_put: put(publish::), @@ -1531,6 +1537,9 @@ where pre_publish: post(pre_publish::), db_reset: put(reset::), timestamp_get: get(get_timestamp::), + http_route_root: any(handle_http_route_root::), + http_route_root_slash: any(handle_http_route_root_slash::), + http_route: any(handle_http_route::), } } } @@ -1540,8 +1549,6 @@ where S: NodeDelegate + ControlStateDelegate + Authorization + Clone + 'static, { pub fn into_router(self, ctx: S) -> axum::Router { - use axum::routing::any; - let db_router = axum::Router::::new() .route("/", self.db_put) .route("/", self.db_get) @@ -1577,9 +1584,9 @@ where // Authorization headers do not trigger early rejection or attach SpacetimeAuth. // Keep these routes merged separately from the authenticated database router. let http_route_router = axum::Router::::new() - .route("/:name_or_identity/route", any(handle_http_route_root::)) - .route("/:name_or_identity/route/", any(handle_http_route_root_slash::)) - .route("/:name_or_identity/route/*path", any(handle_http_route::)); + .route("/:name_or_identity/route", self.http_route_root) + .route("/:name_or_identity/route/", self.http_route_root_slash) + .route("/:name_or_identity/route/*path", self.http_route); axum::Router::new() .merge(authed_root_router) From cadc2248b7345cdaca5afca3c83ebadff1f66bb9 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 19 May 2026 11:30:35 -0400 Subject: [PATCH 37/47] Add missing comma in merged autogen file --- .../Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs index 98c409e9d4c..576ec402c0e 100644 --- a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs @@ -20,7 +20,7 @@ public partial record RawModuleDefV10Section : SpacetimeDB.TaggedEnum<( System.Collections.Generic.List RowLevelSecurity, SpacetimeDB.CaseConversionPolicy CaseConversionPolicy, ExplicitNames ExplicitNames, - System.Collections.Generic.List Mounts + System.Collections.Generic.List Mounts, System.Collections.Generic.List HttpHandlers, System.Collections.Generic.List HttpRoutes )>; From 67c68909aa16a002381c55f3fb9c8998f2e152be Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Thu, 21 May 2026 14:01:25 -0700 Subject: [PATCH 38/47] C# HTTP handlers - Module Bindings (#5024) # Description of Changes Adding C# HTTP handlers based on #4636 - adds the C# handler/router API `[SpacetimeDB.HttpHandler]`, `[SpacetimeDB.HttpRouter]` - wires C# HTTP handlers into module definition/build/runtime registration - mirrors the Rust/TypeScript HTTP smoketests and adds C# docs coverage - updates the HTTP handlers docs with C# examples - refactored ProcedureContext to allow for a central location for WithTx/TryWithTx to support HandlerContext - routes use a generated `Handlers.*` tokens to avoid raw strings # API and ABI breaking changes Adds new APIs for the HTTP handler and should not be breaking # Expected complexity level and risk 3 - this hit the binding, module registration, and had a decent refactor for ProcedureContext # Testing - [x] Expanded `crates/smoketests/tests/smoketests/http_routes.rs` with C# mirrors of the Rust HTTP route tests I also did some manual testing with a throw away project, and will be adding to the `module-test` after all languages are caught up on HTTP handlers. --- .../ExtraCompilationErrors.verified.txt | 2 +- .../diag/snapshots/Module#FFI.verified.cs | 73 +++ .../snapshots/Module#FFI.verified.cs | 72 +++ .../server/snapshots/Module#FFI.verified.cs | 73 +++ crates/bindings-csharp/Codegen/Diag.cs | 66 +++ crates/bindings-csharp/Codegen/Module.cs | 404 +++++++++++++- .../Runtime.Tests/RouterTests.cs | 110 ++++ crates/bindings-csharp/Runtime/Attrs.cs | 6 + .../bindings-csharp/Runtime/HandlerContext.cs | 117 ++++ crates/bindings-csharp/Runtime/Http.cs | 59 ++- .../Runtime/Internal/IHttpHandler.cs | 11 + .../Runtime/Internal/Module.cs | 96 ++++ .../Runtime/ProcedureContext.cs | 210 ++------ crates/bindings-csharp/Runtime/Router.cs | 164 ++++++ .../Runtime/TransactionalContextState.cs | 199 +++++++ crates/bindings-csharp/Runtime/bindings.c | 29 +- .../tests/smoketests/http_routes.rs | 499 ++++++++++++++++-- .../00200-functions/00600-HTTP-handlers.md | 98 ++++ 18 files changed, 2026 insertions(+), 262 deletions(-) create mode 100644 crates/bindings-csharp/Runtime.Tests/RouterTests.cs create mode 100644 crates/bindings-csharp/Runtime/HandlerContext.cs create mode 100644 crates/bindings-csharp/Runtime/Internal/IHttpHandler.cs create mode 100644 crates/bindings-csharp/Runtime/Router.cs create mode 100644 crates/bindings-csharp/Runtime/TransactionalContextState.cs diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/ExtraCompilationErrors.verified.txt b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/ExtraCompilationErrors.verified.txt index c732156f772..8d65f041f30 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/ExtraCompilationErrors.verified.txt +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/ExtraCompilationErrors.verified.txt @@ -23,7 +23,7 @@ } }, {/* -SpacetimeDB.Internal.Module.RegisterTable(); + SpacetimeDB.Internal.Module.RegisterClientVisibilityFilter(global::Module.MY_FILTER); ^^^^^^^^^ SpacetimeDB.Internal.Module.RegisterClientVisibilityFilter(global::Module.MY_FOURTH_FILTER); diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs index bb33a8b65e8..2504cc013bf 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs @@ -645,6 +645,8 @@ public readonly partial struct QueryBuilder ); } + public static class Handlers { } + public sealed record ReducerContext : DbContext, Internal.IReducerContext { public readonly Identity Sender; @@ -808,6 +810,46 @@ public Uuid NewUuidV7() } } + public sealed partial class HandlerContext : global::SpacetimeDB.HandlerContextBase + { + private readonly Local _db = new(); + + internal HandlerContext(Random random, Timestamp time) + : base(random, time) { } + + protected override global::SpacetimeDB.LocalBase CreateLocal() => _db; + + protected override global::SpacetimeDB.HandlerTxContextBase CreateTxContext( + Internal.TxContext inner + ) => _cached ??= new HandlerTxContext(inner); + + private HandlerTxContext? _cached; + + [Experimental("STDB_UNSTABLE")] + public TResult WithTx(Func body) => + base.WithTx(tx => body((HandlerTxContext)tx)); + + [Experimental("STDB_UNSTABLE")] + public TxOutcome TryWithTx( + Func> body + ) + where TError : Exception => base.TryWithTx(tx => body((HandlerTxContext)tx)); + + public Uuid NewUuidV4() + { + var bytes = new byte[16]; + Rng.NextBytes(bytes); + return Uuid.FromRandomBytesV4(bytes); + } + + public Uuid NewUuidV7() + { + var bytes = new byte[4]; + Rng.NextBytes(bytes); + return Uuid.FromCounterV7(ref CounterUuid, Timestamp, bytes); + } + } + [Experimental("STDB_UNSTABLE")] public sealed class ProcedureTxContext : global::SpacetimeDB.ProcedureTxContextBase { @@ -817,6 +859,15 @@ internal ProcedureTxContext(Internal.TxContext inner) public new Local Db => (Local)base.Db; } + [Experimental("STDB_UNSTABLE")] + public sealed class HandlerTxContext : global::SpacetimeDB.HandlerTxContextBase + { + internal HandlerTxContext(Internal.TxContext inner) + : base(inner) { } + + public new Local Db => (Local)base.Db; + } + public sealed class Local : global::SpacetimeDB.LocalBase { public global::SpacetimeDB.Internal.TableHandles.Player Player => new(); @@ -3184,6 +3235,9 @@ public static void Main() "TestCanonicalNameWithoutAccessor" ); + SpacetimeDB.Internal.Module.SetHandlerContextConstructor( + (random, time) => new SpacetimeDB.HandlerContext(random, time) + ); var __memoryStream = new MemoryStream(); var __writer = new BinaryWriter(__memoryStream); @@ -3256,6 +3310,7 @@ public static void Main() global::TestUniqueNotEquatable, SpacetimeDB.Internal.TableHandles.TestUniqueNotEquatable >(); + SpacetimeDB.Internal.Module.RegisterClientVisibilityFilter(global::Module.MY_FILTER); SpacetimeDB.Internal.Module.RegisterClientVisibilityFilter(global::Module.MY_FOURTH_FILTER); SpacetimeDB.Internal.Module.RegisterClientVisibilityFilter(global::Module.MY_SECOND_FILTER); @@ -3527,6 +3582,24 @@ SpacetimeDB.Internal.BytesSink result_sink result_sink ); + [UnmanagedCallersOnly(EntryPoint = "__call_http_handler__")] + public static SpacetimeDB.Internal.Errno __call_http_handler__( + uint id, + SpacetimeDB.Timestamp timestamp, + SpacetimeDB.Internal.BytesSource request, + SpacetimeDB.Internal.BytesSource request_body, + SpacetimeDB.Internal.BytesSink response_sink, + SpacetimeDB.Internal.BytesSink response_body_sink + ) => + SpacetimeDB.Internal.Module.__call_http_handler__( + id, + timestamp, + request, + request_body, + response_sink, + response_body_sink + ); + [UnmanagedCallersOnly(EntryPoint = "__call_view__")] public static SpacetimeDB.Internal.Errno __call_view__( uint id, diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs index c963576250f..b46de3189b4 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs @@ -47,6 +47,8 @@ public readonly partial struct QueryBuilder new("DemoTable", new DemoTableCols("DemoTable"), new DemoTableIxCols("DemoTable")); } + public static class Handlers { } + public sealed record ReducerContext : DbContext, Internal.IReducerContext { public readonly Identity Sender; @@ -210,6 +212,46 @@ public Uuid NewUuidV7() } } + public sealed partial class HandlerContext : global::SpacetimeDB.HandlerContextBase + { + private readonly Local _db = new(); + + internal HandlerContext(Random random, Timestamp time) + : base(random, time) { } + + protected override global::SpacetimeDB.LocalBase CreateLocal() => _db; + + protected override global::SpacetimeDB.HandlerTxContextBase CreateTxContext( + Internal.TxContext inner + ) => _cached ??= new HandlerTxContext(inner); + + private HandlerTxContext? _cached; + + [Experimental("STDB_UNSTABLE")] + public TResult WithTx(Func body) => + base.WithTx(tx => body((HandlerTxContext)tx)); + + [Experimental("STDB_UNSTABLE")] + public TxOutcome TryWithTx( + Func> body + ) + where TError : Exception => base.TryWithTx(tx => body((HandlerTxContext)tx)); + + public Uuid NewUuidV4() + { + var bytes = new byte[16]; + Rng.NextBytes(bytes); + return Uuid.FromRandomBytesV4(bytes); + } + + public Uuid NewUuidV7() + { + var bytes = new byte[4]; + Rng.NextBytes(bytes); + return Uuid.FromCounterV7(ref CounterUuid, Timestamp, bytes); + } + } + [Experimental("STDB_UNSTABLE")] public sealed class ProcedureTxContext : global::SpacetimeDB.ProcedureTxContextBase { @@ -219,6 +261,15 @@ internal ProcedureTxContext(Internal.TxContext inner) public new Local Db => (Local)base.Db; } + [Experimental("STDB_UNSTABLE")] + public sealed class HandlerTxContext : global::SpacetimeDB.HandlerTxContextBase + { + internal HandlerTxContext(Internal.TxContext inner) + : base(inner) { } + + public new Local Db => (Local)base.Db; + } + public sealed class Local : global::SpacetimeDB.LocalBase { public global::SpacetimeDB.Internal.TableHandles.DemoTable DemoTable => new(); @@ -560,6 +611,9 @@ public static void Main() "canonical_index" ); + SpacetimeDB.Internal.Module.SetHandlerContextConstructor( + (random, time) => new SpacetimeDB.HandlerContext(random, time) + ); var __memoryStream = new MemoryStream(); var __writer = new BinaryWriter(__memoryStream); @@ -635,6 +689,24 @@ SpacetimeDB.Internal.BytesSink result_sink result_sink ); + [UnmanagedCallersOnly(EntryPoint = "__call_http_handler__")] + public static SpacetimeDB.Internal.Errno __call_http_handler__( + uint id, + SpacetimeDB.Timestamp timestamp, + SpacetimeDB.Internal.BytesSource request, + SpacetimeDB.Internal.BytesSource request_body, + SpacetimeDB.Internal.BytesSink response_sink, + SpacetimeDB.Internal.BytesSink response_body_sink + ) => + SpacetimeDB.Internal.Module.__call_http_handler__( + id, + timestamp, + request, + request_body, + response_sink, + response_body_sink + ); + [UnmanagedCallersOnly(EntryPoint = "__call_view__")] public static SpacetimeDB.Internal.Errno __call_view__( uint id, diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs index 4c6be2c99c4..edc7d5f2af4 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs @@ -489,6 +489,8 @@ public readonly partial struct QueryBuilder ); } + public static class Handlers { } + public sealed record ReducerContext : DbContext, Internal.IReducerContext { public readonly Identity Sender; @@ -652,6 +654,46 @@ public Uuid NewUuidV7() } } + public sealed partial class HandlerContext : global::SpacetimeDB.HandlerContextBase + { + private readonly Local _db = new(); + + internal HandlerContext(Random random, Timestamp time) + : base(random, time) { } + + protected override global::SpacetimeDB.LocalBase CreateLocal() => _db; + + protected override global::SpacetimeDB.HandlerTxContextBase CreateTxContext( + Internal.TxContext inner + ) => _cached ??= new HandlerTxContext(inner); + + private HandlerTxContext? _cached; + + [Experimental("STDB_UNSTABLE")] + public TResult WithTx(Func body) => + base.WithTx(tx => body((HandlerTxContext)tx)); + + [Experimental("STDB_UNSTABLE")] + public TxOutcome TryWithTx( + Func> body + ) + where TError : Exception => base.TryWithTx(tx => body((HandlerTxContext)tx)); + + public Uuid NewUuidV4() + { + var bytes = new byte[16]; + Rng.NextBytes(bytes); + return Uuid.FromRandomBytesV4(bytes); + } + + public Uuid NewUuidV7() + { + var bytes = new byte[4]; + Rng.NextBytes(bytes); + return Uuid.FromCounterV7(ref CounterUuid, Timestamp, bytes); + } + } + [Experimental("STDB_UNSTABLE")] public sealed class ProcedureTxContext : global::SpacetimeDB.ProcedureTxContextBase { @@ -661,6 +703,15 @@ internal ProcedureTxContext(Internal.TxContext inner) public new Local Db => (Local)base.Db; } + [Experimental("STDB_UNSTABLE")] + public sealed class HandlerTxContext : global::SpacetimeDB.HandlerTxContextBase + { + internal HandlerTxContext(Internal.TxContext inner) + : base(inner) { } + + public new Local Db => (Local)base.Db; + } + public sealed class Local : global::SpacetimeDB.LocalBase { internal global::SpacetimeDB.Internal.TableHandles.BTreeMultiColumn BTreeMultiColumn => @@ -2450,6 +2501,9 @@ public static void Main() (identity, connectionId, random, time) => new SpacetimeDB.ProcedureContext(identity, connectionId, random, time) ); + SpacetimeDB.Internal.Module.SetHandlerContextConstructor( + (random, time) => new SpacetimeDB.HandlerContext(random, time) + ); var __memoryStream = new MemoryStream(); var __writer = new BinaryWriter(__memoryStream); @@ -2499,6 +2553,7 @@ public static void Main() global::Timers.SendMessageTimer, SpacetimeDB.Internal.TableHandles.SendMessageTimer >(); + SpacetimeDB.Internal.Module.RegisterClientVisibilityFilter( global::Module.ALL_PUBLIC_TABLES ); @@ -2562,6 +2617,24 @@ SpacetimeDB.Internal.BytesSink result_sink result_sink ); + [UnmanagedCallersOnly(EntryPoint = "__call_http_handler__")] + public static SpacetimeDB.Internal.Errno __call_http_handler__( + uint id, + SpacetimeDB.Timestamp timestamp, + SpacetimeDB.Internal.BytesSource request, + SpacetimeDB.Internal.BytesSource request_body, + SpacetimeDB.Internal.BytesSink response_sink, + SpacetimeDB.Internal.BytesSink response_body_sink + ) => + SpacetimeDB.Internal.Module.__call_http_handler__( + id, + timestamp, + request, + request_body, + response_sink, + response_body_sink + ); + [UnmanagedCallersOnly(EntryPoint = "__call_view__")] public static SpacetimeDB.Internal.Errno __call_view__( uint id, diff --git a/crates/bindings-csharp/Codegen/Diag.cs b/crates/bindings-csharp/Codegen/Diag.cs index 383fe058448..928b7a71635 100644 --- a/crates/bindings-csharp/Codegen/Diag.cs +++ b/crates/bindings-csharp/Codegen/Diag.cs @@ -267,4 +267,70 @@ string typeName $"Index attribute on a table declaration must specify Accessor. Field-level index attributes may omit Accessor and default to the field name.", attr => attr ); + + public static readonly ErrorDescriptor HttpHandlerContextParam = + new( + group, + "HTTP handlers must have a first argument of type HandlerContext", + method => + $"HTTP handler method {method.Identifier} does not have a HandlerContext parameter.", + method => method.ParameterList + ); + + public static readonly ErrorDescriptor HttpHandlerRequestParam = + new( + group, + "HTTP handlers must have a second argument of type HttpRequest", + method => + $"HTTP handler method {method.Identifier} does not have an HttpRequest parameter.", + method => method.ParameterList + ); + + public static readonly ErrorDescriptor HttpHandlerReturnType = + new( + group, + "HTTP handlers must return HttpResponse", + method => + $"HTTP handler method {method.Identifier} returns {method.ReturnType} instead of HttpResponse.", + method => method.ReturnType + ); + + public static readonly ErrorDescriptor HttpRouterSignature = + new( + group, + "HTTP routers must be static parameterless methods returning Router", + method => + $"HTTP router method {method.Identifier} must be static, parameterless, and return Router.", + method => method + ); + + public static readonly ErrorDescriptor<( + MethodDeclarationSyntax method, + string prefix + )> HttpHandlerReservedPrefix = + new( + group, + "HTTP handler method has a reserved name prefix", + ctx => + $"HTTP handler method {ctx.method.Identifier} starts with '{ctx.prefix}', which is a reserved prefix.", + ctx => ctx.method.Identifier + ); + + public static readonly ErrorDescriptor> DuplicateHttpRouters = + new( + group, + "Multiple [SpacetimeDB.HttpRouter] declarations", + fullNames => + $"[SpacetimeDB.HttpRouter] is declared multiple times: {string.Join(", ", fullNames)}", + _ => Location.None + ); + + public static readonly ErrorDescriptor HttpHandlerSignature = + new( + group, + "HTTP handlers must be non-generic methods with exactly two parameters", + method => + $"HTTP handler method {method.Identifier} must be non-generic and take exactly two parameters.", + method => method.ParameterList + ); } diff --git a/crates/bindings-csharp/Codegen/Module.cs b/crates/bindings-csharp/Codegen/Module.cs index f6ffa50b941..870cadce093 100644 --- a/crates/bindings-csharp/Codegen/Module.cs +++ b/crates/bindings-csharp/Codegen/Module.cs @@ -1750,6 +1750,147 @@ public Scope.Extensions GenerateSchedule() } } +record HttpHandlerDeclaration +{ + public readonly string Name; + public readonly string FullName; + private readonly bool HasWrongSignature; + + public string Identifier => EscapeIdentifier(Name); + + public HttpHandlerDeclaration(GeneratorAttributeSyntaxContext context, DiagReporter diag) + { + var methodSyntax = (MethodDeclarationSyntax)context.TargetNode; + var method = (IMethodSymbol)context.TargetSymbol; + var compilation = context.SemanticModel.Compilation; + + if (method.Arity != 0 || method.Parameters.Length != 2) + { + diag.Report(ErrorDescriptor.HttpHandlerSignature, methodSyntax); + HasWrongSignature = true; + } + + if ( + method.Parameters.FirstOrDefault()?.Type + is not INamedTypeSymbol + { + Name: "HandlerContext", + Arity: 0, + ContainingType: null, + ContainingNamespace: + { Name: "SpacetimeDB", ContainingNamespace: { IsGlobalNamespace: true } } + } + && methodSyntax.ParameterList.Parameters.FirstOrDefault()?.Type + is not IdentifierNameSyntax { Identifier.ValueText: "HandlerContext" } + && methodSyntax.ParameterList.Parameters.FirstOrDefault()?.Type + is not QualifiedNameSyntax + { + Left: IdentifierNameSyntax { Identifier.ValueText: "SpacetimeDB" }, + Right: IdentifierNameSyntax { Identifier.ValueText: "HandlerContext" } + } + && methodSyntax.ParameterList.Parameters.FirstOrDefault()?.Type + is not QualifiedNameSyntax + { + Left: AliasQualifiedNameSyntax + { + Alias.Identifier.ValueText: "global", + Name: IdentifierNameSyntax { Identifier.ValueText: "SpacetimeDB" } + }, + Right: IdentifierNameSyntax { Identifier.ValueText: "HandlerContext" } + } + ) + { + diag.Report(ErrorDescriptor.HttpHandlerContextParam, methodSyntax); + HasWrongSignature = true; + } + + if ( + method.Parameters.ElementAtOrDefault(1)?.Type is not { } requestType + || compilation.GetTypeByMetadataName("SpacetimeDB.HttpRequest") + is not { } expectedRequestType + || !SymbolEqualityComparer.Default.Equals(requestType, expectedRequestType) + ) + { + diag.Report(ErrorDescriptor.HttpHandlerRequestParam, methodSyntax); + HasWrongSignature = true; + } + + if ( + compilation.GetTypeByMetadataName("SpacetimeDB.HttpResponse") + is not { } expectedResponseType + || !SymbolEqualityComparer.Default.Equals(method.ReturnType, expectedResponseType) + ) + { + diag.Report(ErrorDescriptor.HttpHandlerReturnType, methodSyntax); + HasWrongSignature = true; + } + + Name = method.Name; + if (Name.Length >= 2) + { + var prefix = Name[..2]; + if (prefix is "__" or "on" or "On") + { + diag.Report(ErrorDescriptor.HttpHandlerReservedPrefix, (methodSyntax, prefix)); + } + } + + FullName = SymbolToName(method); + } + + public string GenerateClass() + { + var body = HasWrongSignature + ? "throw new System.InvalidOperationException(\"Invalid HTTP handler signature.\");" + : $"return {FullName}((SpacetimeDB.HandlerContext)ctx, request);"; + + return $$""" + class {{Identifier}} : SpacetimeDB.Internal.IHttpHandler { + public SpacetimeDB.Internal.RawHttpHandlerDefV10 MakeHandlerDef() => new( + SourceName: nameof({{Identifier}}) + ); + + public SpacetimeDB.HttpResponse Invoke( + SpacetimeDB.HandlerContextBase ctx, + SpacetimeDB.HttpRequest request + ) { + {{body}} + } + } + """; + } +} + +record HttpRouterDeclaration +{ + public readonly string FullName; + public readonly bool IsValid; + + public HttpRouterDeclaration(GeneratorAttributeSyntaxContext context, DiagReporter diag) + { + var methodSyntax = (MethodDeclarationSyntax)context.TargetNode; + var method = (IMethodSymbol)context.TargetSymbol; + var compilation = context.SemanticModel.Compilation; + + if ( + !method.IsStatic + || method.Arity != 0 + || method.Parameters.Length != 0 + || compilation.GetTypeByMetadataName("SpacetimeDB.Router") is not { } expectedRouterType + || !SymbolEqualityComparer.Default.Equals(method.ReturnType, expectedRouterType) + ) + { + diag.Report(ErrorDescriptor.HttpRouterSignature, methodSyntax); + } + else + { + IsValid = true; + } + + FullName = SymbolToName(method); + } +} + record ClientVisibilityFilterDeclaration { public readonly string FullName; @@ -1859,6 +2000,93 @@ Func toFullName .WithTrackingName($"SpacetimeDB.{kind}.Collect"); } + private static ( + TTableAccessors tableAccessors, + TSettings settings, + TTableDecls tableDecls, + TReducers addReducers, + TProcedures addProcedures, + THttpHandlers addHttpHandlers, + TReadOnlyAccessors readOnlyAccessors, + THttpRouters httpRouters, + TViews views, + TRlsFilters rlsFilters, + TColumnDefaultValues columnDefaultValues + ) FlattenModuleOutputInputs< + TTableAccessors, + TSettings, + TTableDecls, + TReducers, + TProcedures, + THttpHandlers, + TReadOnlyAccessors, + THttpRouters, + TViews, + TRlsFilters, + TColumnDefaultValues + >( + ( + ( + ( + ( + ( + ( + ( + (((TTableAccessors, TSettings), TTableDecls), TReducers), + TProcedures + ), + THttpHandlers + ), + TReadOnlyAccessors + ), + THttpRouters + ), + TViews + ), + TRlsFilters + ), + TColumnDefaultValues + ) tuple + ) + { + var ( + ( + ( + ( + ( + ( + ( + (((tableAccessors, settings), tableDecls), addReducers), + addProcedures + ), + addHttpHandlers + ), + readOnlyAccessors + ), + httpRouters + ), + views + ), + rlsFilters + ), + columnDefaultValues + ) = tuple; + + return ( + tableAccessors, + settings, + tableDecls, + addReducers, + addProcedures, + addHttpHandlers, + readOnlyAccessors, + httpRouters, + views, + rlsFilters, + columnDefaultValues + ); + } + public void Initialize(IncrementalGeneratorInitializationContext context) { var settings = context @@ -1987,6 +2215,38 @@ public void Initialize(IncrementalGeneratorInitializationContext context) p => p.FullName ); + var httpHandlers = context + .SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: typeof(HttpHandlerAttribute).FullName, + predicate: (node, ct) => true, + transform: (context, ct) => + context.ParseWithDiags(diag => new HttpHandlerDeclaration(context, diag)) + ) + .ReportDiagnostics(context) + .WithTrackingName("SpacetimeDB.HttpHandler.Parse"); + + var addHttpHandlers = CollectDistinct( + "HttpHandler", + context, + httpHandlers + .Select((h, ct) => (h.Name, h.FullName, Class: h.GenerateClass())) + .WithTrackingName("SpacetimeDB.HttpHandler.GenerateClass"), + h => h.Name, + h => h.FullName + ); + + var httpRouters = context + .SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: typeof(HttpRouterAttribute).FullName, + predicate: (node, ct) => true, + transform: (context, ct) => + context.ParseWithDiags(diag => new HttpRouterDeclaration(context, diag)) + ) + .ReportDiagnostics(context) + .Collect() + .Select((routers, ct) => new EquatableArray(routers)) + .WithTrackingName("SpacetimeDB.HttpRouter.Collect"); + var tableAccessors = CollectDistinct( "Table", context, @@ -2040,36 +2300,38 @@ public void Initialize(IncrementalGeneratorInitializationContext context) v => v.tableName + "_" + v.columnId ); + var moduleOutputInputs = tableAccessors + .Combine(settingsArray) + .Combine(tableDecls) + .Combine(addReducers) + .Combine(addProcedures) + .Combine(addHttpHandlers) + .Combine(readOnlyAccessors) + .Combine(httpRouters) + .Combine(views) + .Combine(rlsFiltersArray) + .Combine(columnDefaultValues) + .Select((tuple, ct) => FlattenModuleOutputInputs(tuple)); + // Register the generated source code with the compilation context as part of module publishing // Once the compilation is complete, the generated code will be used to create tables and reducers in the database context.RegisterSourceOutput( - tableAccessors - .Combine(settingsArray) - .Combine(tableDecls) - .Combine(addReducers) - .Combine(addProcedures) - .Combine(readOnlyAccessors) - .Combine(views) - .Combine(rlsFiltersArray) - .Combine(columnDefaultValues), - (context, tuple) => + moduleOutputInputs, + (context, inputs) => { var ( - ( - ( - ( - ( - (((tableAccessors, settings), tableDecls), addReducers), - addProcedures - ), - readOnlyAccessors - ), - views - ), - rlsFilters - ), + tableAccessors, + settings, + tableDecls, + addReducers, + addProcedures, + addHttpHandlers, + readOnlyAccessors, + httpRouters, + views, + rlsFilters, columnDefaultValues - ) = tuple; + ) = inputs; if (settings.Array.Length > 1) { @@ -2080,6 +2342,15 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ); } + if (httpRouters.Array.Length > 1) + { + context.ReportDiagnostic( + ErrorDescriptor.DuplicateHttpRouters.ToDiag( + httpRouters.Array.Select(r => r.FullName) + ) + ); + } + var settingsRegistration = settings.Array.Length == 1 && settings.Array[0].CaseConversionPolicy is { } policyName @@ -2153,11 +2424,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) "\n", tableDecls.Array.SelectMany(t => t.GenerateQueryBuilderMembers()) ); + if (string.IsNullOrWhiteSpace(queryBuilderMembers)) + { + queryBuilderMembers = "public readonly partial struct QueryBuilder { }"; + } // Don't generate the FFI boilerplate if there are no tables or reducers. if ( tableAccessors.Array.IsEmpty && addReducers.Array.IsEmpty && addProcedures.Array.IsEmpty + && addHttpHandlers.Array.IsEmpty ) { return; @@ -2181,6 +2457,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) namespace SpacetimeDB { {{queryBuilderMembers}} + public static class Handlers { + {{string.Join("\n", addHttpHandlers.Select(r => + $"public static readonly global::SpacetimeDB.Handler {EscapeIdentifier(r.Name)} = new(nameof({r.FullName}));" + ))}} + } public sealed record ReducerContext : DbContext, Internal.IReducerContext { public readonly Identity Sender; public readonly ConnectionId? ConnectionId; @@ -2325,6 +2606,43 @@ public Uuid NewUuidV7() } } + public sealed partial class HandlerContext : global::SpacetimeDB.HandlerContextBase { + private readonly Local _db = new(); + + internal HandlerContext(Random random, Timestamp time) + : base(random, time) {} + + protected override global::SpacetimeDB.LocalBase CreateLocal() => _db; + protected override global::SpacetimeDB.HandlerTxContextBase CreateTxContext(Internal.TxContext inner) => + _cached ??= new HandlerTxContext(inner); + + private HandlerTxContext? _cached; + + [Experimental("STDB_UNSTABLE")] + public TResult WithTx(Func body) => + base.WithTx(tx => body((HandlerTxContext)tx)); + + [Experimental("STDB_UNSTABLE")] + public TxOutcome TryWithTx( + Func> body) + where TError : Exception => + base.TryWithTx(tx => body((HandlerTxContext)tx)); + + public Uuid NewUuidV4() + { + var bytes = new byte[16]; + Rng.NextBytes(bytes); + return Uuid.FromRandomBytesV4(bytes); + } + + public Uuid NewUuidV7() + { + var bytes = new byte[4]; + Rng.NextBytes(bytes); + return Uuid.FromCounterV7(ref CounterUuid, Timestamp, bytes); + } + } + [Experimental("STDB_UNSTABLE")] public sealed class ProcedureTxContext : global::SpacetimeDB.ProcedureTxContextBase { internal ProcedureTxContext(Internal.TxContext inner) : base(inner) {} @@ -2332,6 +2650,13 @@ internal ProcedureTxContext(Internal.TxContext inner) : base(inner) {} public new Local Db => (Local)base.Db; } + [Experimental("STDB_UNSTABLE")] + public sealed class HandlerTxContext : global::SpacetimeDB.HandlerTxContextBase { + internal HandlerTxContext(Internal.TxContext inner) : base(inner) {} + + public new Local Db => (Local)base.Db; + } + public sealed class Local : global::SpacetimeDB.LocalBase { {{string.Join("\n", tableAccessors.Select(v => v.getter))}} } @@ -2386,6 +2711,8 @@ static class ModuleRegistration { {{string.Join("\n", addProcedures.Select(r => r.Class))}} + {{string.Join("\n", addHttpHandlers.Select(r => r.Class))}} + public static List ToListOrEmpty(T? value) where T : struct => value is null ? new List() : new List { value.Value }; @@ -2405,6 +2732,7 @@ public static void Main() { SpacetimeDB.Internal.Module.SetViewContextConstructor(identity => new SpacetimeDB.ViewContext(identity, new SpacetimeDB.Internal.LocalReadOnly())); SpacetimeDB.Internal.Module.SetAnonymousViewContextConstructor(() => new SpacetimeDB.AnonymousViewContext(new SpacetimeDB.Internal.LocalReadOnly())); SpacetimeDB.Internal.Module.SetProcedureContextConstructor((identity, connectionId, random, time) => new SpacetimeDB.ProcedureContext(identity, connectionId, random, time));{{preRegistrations}} + SpacetimeDB.Internal.Module.SetHandlerContextConstructor((random, time) => new SpacetimeDB.HandlerContext(random, time)); var __memoryStream = new MemoryStream(); var __writer = new BinaryWriter(__memoryStream); @@ -2420,6 +2748,12 @@ public static void Main() { $"SpacetimeDB.Internal.Module.RegisterProcedure<{EscapeIdentifier(r.Name)}>();" ) )}} + {{string.Join( + "\n", + addHttpHandlers.Select(r => + $"SpacetimeDB.Internal.Module.RegisterHttpHandler<{EscapeIdentifier(r.Name)}>();" + ) + )}} // IMPORTANT: The order in which we register views matters. // It must correspond to the order in which we call `GenerateDispatcherClass`. @@ -2437,6 +2771,11 @@ public static void Main() { "\n", tableAccessors.Select(t => $"SpacetimeDB.Internal.Module.RegisterTable<{t.tableName}, SpacetimeDB.Internal.TableHandles.{EscapeIdentifier(t.tableAccessorName)}>();") )}} + {{( + httpRouters.Array.FirstOrDefault(r => r.IsValid) is { } router + ? $"SpacetimeDB.Internal.Module.RegisterHttpRouter({router.FullName}());" + : string.Empty + )}} {{string.Join( "\n", rlsFilters.Select(f => $"SpacetimeDB.Internal.Module.RegisterClientVisibilityFilter({f.GlobalName});") @@ -2509,6 +2848,23 @@ SpacetimeDB.Internal.BytesSink result_sink args, result_sink ); + + [UnmanagedCallersOnly(EntryPoint = "__call_http_handler__")] + public static SpacetimeDB.Internal.Errno __call_http_handler__( + uint id, + SpacetimeDB.Timestamp timestamp, + SpacetimeDB.Internal.BytesSource request, + SpacetimeDB.Internal.BytesSource request_body, + SpacetimeDB.Internal.BytesSink response_sink, + SpacetimeDB.Internal.BytesSink response_body_sink + ) => SpacetimeDB.Internal.Module.__call_http_handler__( + id, + timestamp, + request, + request_body, + response_sink, + response_body_sink + ); [UnmanagedCallersOnly(EntryPoint = "__call_view__")] public static SpacetimeDB.Internal.Errno __call_view__( diff --git a/crates/bindings-csharp/Runtime.Tests/RouterTests.cs b/crates/bindings-csharp/Runtime.Tests/RouterTests.cs new file mode 100644 index 00000000000..3bd57d32508 --- /dev/null +++ b/crates/bindings-csharp/Runtime.Tests/RouterTests.cs @@ -0,0 +1,110 @@ +namespace Runtime.Tests; + +using SpacetimeDB; + +public class RouterTests +{ + private static class TestHandlers + { + public static readonly Handler GetHandler = new(nameof(RouterTests.GetHandler)); + public static readonly Handler PostHandler = new(nameof(RouterTests.PostHandler)); + } + + [Fact] + public void AllowsDistinctMethodsOnSamePath() + { + var router = Router + .New() + .Get("/hooks", TestHandlers.GetHandler) + .Post("/hooks", TestHandlers.PostHandler); + + Assert.NotNull(router); + } + + [Fact] + public void RejectsAnyConflictOnSamePath() + { + var ex = Assert.Throws( + () => + Router + .New() + .Any("/hooks", TestHandlers.GetHandler) + .Get("/hooks", TestHandlers.PostHandler) + ); + + Assert.Contains("Route conflict", ex.Message); + } + + [Fact] + public void RejectsInvalidPathCharacters() + { + var ex = Assert.Throws( + () => Router.New().Get("/Bad", TestHandlers.GetHandler) + ); + + Assert.Contains("Route paths may contain only", ex.Message); + } + + [Fact] + public void NestJoinsPathsWithoutDoubleSlash() + { + var router = Router.New().Nest("/api", Router.New().Get("/hooks", TestHandlers.GetHandler)); + + Assert.NotNull(router); + } + + [Fact] + public void NestRejectsExistingSiblingPrefix() + { + var ex = Assert.Throws( + () => + Router + .New() + .Get("/apiv2", TestHandlers.GetHandler) + .Nest("/api", Router.New().Get("/hooks", TestHandlers.PostHandler)) + ); + + Assert.Contains("Cannot nest router", ex.Message); + } + + [Fact] + public void NestRejectsExistingRouteAtNestedPrefix() + { + var ex = Assert.Throws( + () => + Router + .New() + .Get("/api", TestHandlers.GetHandler) + .Nest("/api", Router.New().Get("/hooks", TestHandlers.PostHandler)) + ); + + Assert.Contains("Cannot nest router", ex.Message); + } + + [Fact] + public void NestStillRejectsExactRouteConflicts() + { + var ex = Assert.Throws( + () => + Router + .New() + .Get("/api/hooks", TestHandlers.GetHandler) + .Nest("/api", Router.New().Get("/hooks", TestHandlers.PostHandler)) + ); + + Assert.Contains("Cannot nest router", ex.Message); + } + + private sealed class TestHandlerContext() : HandlerContextBase(new System.Random(), default) + { + protected override HandlerTxContextBase CreateTxContext( + SpacetimeDB.Internal.TxContext inner + ) => throw new NotSupportedException(); + + protected internal override LocalBase CreateLocal() => throw new NotSupportedException(); + } + + private static HttpResponse GetHandler(TestHandlerContext _, HttpRequest __) => default; + + private static HttpResponse PostHandler(TestHandlerContext _, HttpRequest __) => default; +} diff --git a/crates/bindings-csharp/Runtime/Attrs.cs b/crates/bindings-csharp/Runtime/Attrs.cs index 67fceffebaf..46daec5eec9 100644 --- a/crates/bindings-csharp/Runtime/Attrs.cs +++ b/crates/bindings-csharp/Runtime/Attrs.cs @@ -203,4 +203,10 @@ public sealed class ProcedureAttribute() : Attribute { public string? Name { get; init; } } + + [AttributeUsage(AttributeTargets.Method, Inherited = false)] + public sealed class HttpHandlerAttribute() : Attribute { } + + [AttributeUsage(AttributeTargets.Method, Inherited = false)] + public sealed class HttpRouterAttribute() : Attribute { } } diff --git a/crates/bindings-csharp/Runtime/HandlerContext.cs b/crates/bindings-csharp/Runtime/HandlerContext.cs new file mode 100644 index 00000000000..8ad7fe14239 --- /dev/null +++ b/crates/bindings-csharp/Runtime/HandlerContext.cs @@ -0,0 +1,117 @@ +namespace SpacetimeDB; + +using System; +using System.Diagnostics.CodeAnalysis; + +#pragma warning disable STDB_UNSTABLE +public abstract class HandlerContextBase +{ + public Random Rng => txState.Rng; + public Timestamp Timestamp => txState.Timestamp; + + // NOTE: The host rejects procedure HTTP requests while a mut transaction is open + // (WOULD_BLOCK_TRANSACTION). Avoid calling `Http.*` inside WithTx. + public HttpClient Http { get; } = new(); + + // **Note:** must be 0..=u32::MAX + protected int CounterUuid = 0; + private readonly TransactionalContextState txState; + + protected HandlerContextBase(Random random, Timestamp time) + { + txState = new( + random, + time, + timestamp => new Internal.TxContext( + CreateLocal(), + default, + null, + timestamp, + AuthCtx.BuildFromSystemTables(null, default), + random + ), + inner => CreateTxContext(inner) + ); + } + + protected abstract HandlerTxContextBase CreateTxContext(Internal.TxContext inner); + protected internal abstract LocalBase CreateLocal(); + + public Internal.TxContext EnterTxContext(long timestampMicros) => + txState.EnterTxContext(timestampMicros); + + public void ExitTxContext() => txState.ExitTxContext(); + + public readonly struct TxOutcome(bool isSuccess, TResult? value, Exception? error) + { + public bool IsSuccess { get; } = isSuccess; + public TResult? Value { get; } = value; + public Exception? Error { get; } = error; + + public static TxOutcome Success(TResult value) => new(true, value, null); + + public static TxOutcome Failure(Exception error) => new(false, default, error); + + public TResult UnwrapOrThrow() => + IsSuccess + ? Value! + : throw ( + Error + ?? new InvalidOperationException("Transaction failed without an error object.") + ); + } + + [Experimental("STDB_UNSTABLE")] + public TResult WithTx(Func body) => + txState.WithTx(body); + + [Experimental("STDB_UNSTABLE")] + public TxOutcome TryWithTx( + Func> body + ) + where TError : Exception + { + var outcome = txState.TryWithTx(body); + return outcome.IsSuccess + ? TxOutcome.Success(outcome.Value!) + : TxOutcome.Failure( + outcome.Error + ?? new InvalidOperationException("Transaction failed without an error object.") + ); + } +} + +public abstract class HandlerTxContextBase(Internal.TxContext inner) : IRefreshableTxContext +{ + internal Internal.TxContext Inner { get; private set; } = inner; + + internal void Refresh(Internal.TxContext inner) => Inner = inner; + + void IRefreshableTxContext.Refresh(Internal.TxContext inner) => Refresh(inner); + + public LocalBase Db => (LocalBase)Inner.Db; + public Timestamp Timestamp => Inner.Timestamp; + public Random Rng => Inner.Rng; +} + +internal sealed partial class RuntimeHandlerContext(Random random, Timestamp timestamp) + : HandlerContextBase(random, timestamp) +{ + private readonly RuntimeLocal _db = new(); + + protected internal override LocalBase CreateLocal() => _db; + + protected override HandlerTxContextBase CreateTxContext(Internal.TxContext inner) => + _cached ??= new RuntimeHandlerTxContext(inner); + + private RuntimeHandlerTxContext? _cached; +} + +internal sealed class RuntimeHandlerTxContext : HandlerTxContextBase +{ + internal RuntimeHandlerTxContext(Internal.TxContext inner) + : base(inner) { } + + public new RuntimeLocal Db => (RuntimeLocal)base.Db; +} +#pragma warning restore STDB_UNSTABLE diff --git a/crates/bindings-csharp/Runtime/Http.cs b/crates/bindings-csharp/Runtime/Http.cs index 6d23dc72ef4..be98b704fcc 100644 --- a/crates/bindings-csharp/Runtime/Http.cs +++ b/crates/bindings-csharp/Runtime/Http.cs @@ -418,13 +418,50 @@ private static HttpVersionWire ToWireVersion(HttpVersion version) => private static HttpHeaderPairWire ToWireHeader(HttpHeader header) => new() { Name = header.Name, Value = header.Value }; - private static ( - ushort statusCode, - HttpVersion version, - List headers - ) FromWireResponse(HttpResponseWire responseWire) - { - var version = responseWire.Version switch + internal static HttpRequest FromWire(HttpRequestWire requestWire, byte[] body) => + new() + { + Uri = requestWire.Uri, + Method = FromWireMethod(requestWire.Method), + Headers = requestWire + .Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)) + .ToList(), + Body = new HttpBody(body), + Version = FromWireVersion(requestWire.Version), + }; + + internal static (HttpResponseWire Response, byte[] Body) ToWire(HttpResponse response) => + ( + new HttpResponseWire + { + Headers = new HttpHeadersWire + { + Entries = response.Headers.Select(ToWireHeader).ToArray(), + }, + Version = ToWireVersion(response.Version), + Code = response.StatusCode, + }, + response.Body.ToBytes() + ); + + private static HttpMethod FromWireMethod(HttpMethodWire methodWire) => + methodWire switch + { + HttpMethodWire.Get => HttpMethod.Get, + HttpMethodWire.Head => HttpMethod.Head, + HttpMethodWire.Post => HttpMethod.Post, + HttpMethodWire.Put => HttpMethod.Put, + HttpMethodWire.Delete => HttpMethod.Delete, + HttpMethodWire.Connect => HttpMethod.Connect, + HttpMethodWire.Options => HttpMethod.Options, + HttpMethodWire.Trace => HttpMethod.Trace, + HttpMethodWire.Patch => HttpMethod.Patch, + HttpMethodWire.Extension(var extension) => new HttpMethod(extension), + _ => throw new InvalidOperationException("Invalid HTTP method returned from host"), + }; + + private static HttpVersion FromWireVersion(HttpVersionWire versionWire) => + versionWire switch { HttpVersionWire.Http09 => HttpVersion.Http09, HttpVersionWire.Http10 => HttpVersion.Http10, @@ -434,6 +471,14 @@ List headers _ => throw new InvalidOperationException("Invalid HTTP version returned from host"), }; + private static ( + ushort statusCode, + HttpVersion version, + List headers + ) FromWireResponse(HttpResponseWire responseWire) + { + var version = FromWireVersion(responseWire.Version); + var headers = responseWire .Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)) .ToList(); diff --git a/crates/bindings-csharp/Runtime/Internal/IHttpHandler.cs b/crates/bindings-csharp/Runtime/Internal/IHttpHandler.cs new file mode 100644 index 00000000000..7c920d1bd90 --- /dev/null +++ b/crates/bindings-csharp/Runtime/Internal/IHttpHandler.cs @@ -0,0 +1,11 @@ +namespace SpacetimeDB.Internal; + +public interface IHttpHandler +{ + RawHttpHandlerDefV10 MakeHandlerDef(); + + SpacetimeDB.HttpResponse Invoke( + SpacetimeDB.HandlerContextBase ctx, + SpacetimeDB.HttpRequest request + ); +} diff --git a/crates/bindings-csharp/Runtime/Internal/Module.cs b/crates/bindings-csharp/Runtime/Internal/Module.cs index fcfe683e5a0..530cc20b402 100644 --- a/crates/bindings-csharp/Runtime/Internal/Module.cs +++ b/crates/bindings-csharp/Runtime/Internal/Module.cs @@ -16,6 +16,8 @@ partial class RawModuleDefV10 private readonly List reducerDefs = []; private readonly List lifecycleReducerDefs = []; private readonly List procedureDefs = []; + private readonly List httpHandlerDefs = []; + private readonly List httpRouteDefs = []; private readonly List viewDefs = []; private readonly List rowLevelSecurityDefs = []; private readonly Dictionary> defaultValuesByTable = @@ -65,6 +67,10 @@ internal void RegisterReducer(RawReducerDefV10 reducer, Lifecycle? lifecycle) internal void RegisterProcedure(RawProcedureDefV10 procedure) => procedureDefs.Add(procedure); + internal void RegisterHttpHandler(RawHttpHandlerDefV10 handler) => httpHandlerDefs.Add(handler); + + internal void RegisterHttpRoute(RawHttpRouteDefV10 route) => httpRouteDefs.Add(route); + internal void RegisterTable(RawTableDefV10 table, RawScheduleDefV10? schedule) { tableDefs.Add(table); @@ -169,6 +175,14 @@ internal RawModuleDefV10 BuildModuleDefinition() { sections.Add(new RawModuleDefV10Section.Procedures(procedureDefs)); } + if (httpHandlerDefs.Count > 0) + { + sections.Add(new RawModuleDefV10Section.HttpHandlers(httpHandlerDefs)); + } + if (httpRouteDefs.Count > 0) + { + sections.Add(new RawModuleDefV10Section.HttpRoutes(httpRouteDefs)); + } if (viewDefs.Count > 0) { sections.Add(new RawModuleDefV10Section.Views(viewDefs)); @@ -240,6 +254,7 @@ private static void EnsureNativeAotTypeRoots() private static readonly List reducers = []; private static readonly List procedures = []; + private static readonly List httpHandlers = []; private static readonly List viewDispatchers = []; private static readonly List anonymousViewDispatchers = []; @@ -252,6 +267,8 @@ private static Func< >? newReducerContext = null; private static Func? newViewContext = null; private static Func? newAnonymousViewContext = null; + private static Func? newHandlerContext = + null; private static Func< Identity, @@ -269,6 +286,10 @@ public static void SetProcedureContextConstructor( Func ctor ) => newProcedureContext = ctor; + public static void SetHandlerContextConstructor( + Func ctor + ) => newHandlerContext = ctor; + public static void SetViewContextConstructor(Func ctor) => newViewContext = ctor; @@ -322,6 +343,40 @@ public static void RegisterProcedure

() moduleDef.RegisterProcedure(procedure.MakeProcedureDef(typeRegistrar)); } + public static void RegisterHttpHandler() + where H : IHttpHandler, new() + { + var handler = new H(); + httpHandlers.Add(handler); + moduleDef.RegisterHttpHandler(handler.MakeHandlerDef()); + } + + public static void RegisterHttpRouter(SpacetimeDB.Router router) + { + foreach (var route in router.GetRoutes()) + { + if ( + !httpHandlers.Any(handler => + handler.MakeHandlerDef().SourceName == route.HandlerFunction + ) + ) + { + throw new ArgumentException( + $"HTTP router references unknown handler `{route.HandlerFunction}`", + nameof(router) + ); + } + + moduleDef.RegisterHttpRoute( + new RawHttpRouteDefV10( + HandlerFunction: route.HandlerFunction, + Method: route.Method, + Path: route.Path + ) + ); + } + } + public static void RegisterTable() where T : IStructuralReadWrite, new() where View : ITableView, new() @@ -554,6 +609,47 @@ BytesSink resultSink } } + public static Errno __call_http_handler__( + uint id, + Timestamp timestamp, + BytesSource request, + BytesSource requestBody, + BytesSink responseSink, + BytesSink responseBodySink + ) + { + try + { + var random = new Random((int)timestamp.MicrosecondsSinceUnixEpoch); + var time = timestamp.ToStd(); + var ctx = newHandlerContext!(random, time); + + var requestBytes = request.Consume(); + using var stream = new MemoryStream(requestBytes); + using var reader = new BinaryReader(stream); + var requestWire = new HttpRequestWire.BSATN().Read(reader); + if (stream.Position != stream.Length) + { + throw new Exception("Unrecognised extra bytes in the HTTP handler request"); + } + + var response = httpHandlers[(int)id] + .Invoke(ctx, SpacetimeDB.HttpClient.FromWire(requestWire, requestBody.Consume())); + var (responseWire, responseBody) = SpacetimeDB.HttpClient.ToWire(response); + responseSink.Write( + IStructuralReadWrite.ToBytes(new HttpResponseWire.BSATN(), responseWire) + ); + responseBodySink.Write(responseBody); + + return Errno.OK; + } + catch (Exception e) + { + Log.Error($"Error while invoking HTTP handler: {e}"); + throw; + } + } + ///

/// Called by the host to execute a view when the sender calls the view identified by . /// diff --git a/crates/bindings-csharp/Runtime/ProcedureContext.cs b/crates/bindings-csharp/Runtime/ProcedureContext.cs index 4eb8583b7d5..9c5a197aa1e 100644 --- a/crates/bindings-csharp/Runtime/ProcedureContext.cs +++ b/crates/bindings-csharp/Runtime/ProcedureContext.cs @@ -3,19 +3,14 @@ namespace SpacetimeDB; using System.Diagnostics.CodeAnalysis; #pragma warning disable STDB_UNSTABLE -public abstract class ProcedureContextBase( - Identity sender, - ConnectionId? connectionId, - Random random, - Timestamp time -) : Internal.IInternalProcedureContext +public abstract class ProcedureContextBase : Internal.IInternalProcedureContext { public static Identity Identity => Internal.IProcedureContext.GetIdentity(); - public Identity Sender { get; } = sender; - public ConnectionId? ConnectionId { get; } = connectionId; - public Random Rng { get; } = random; - public Timestamp Timestamp { get; private set; } = time; - public AuthCtx SenderAuth { get; } = AuthCtx.BuildFromSystemTables(connectionId, sender); + public Identity Sender { get; } + public ConnectionId? ConnectionId { get; } + public Random Rng => txState.Rng; + public Timestamp Timestamp => txState.Timestamp; + public AuthCtx SenderAuth { get; } // NOTE: The host rejects procedure HTTP requests while a mut transaction is open // (WOULD_BLOCK_TRANSACTION). Avoid calling `Http.*` inside WithTx. @@ -23,40 +18,40 @@ Timestamp time // **Note:** must be 0..=u32::MAX protected int CounterUuid = 0; - private Internal.TxContext? txContext; - private ProcedureTxContextBase? cachedUserTxContext; - - protected abstract ProcedureTxContextBase CreateTxContext(Internal.TxContext inner); - protected internal abstract LocalBase CreateLocal(); - - private protected ProcedureTxContextBase RequireTxContext() - { - var inner = - txContext - ?? throw new InvalidOperationException("Transaction context was not initialised."); - cachedUserTxContext ??= CreateTxContext(inner); - cachedUserTxContext.Refresh(inner); - return cachedUserTxContext; - } + private readonly TransactionalContextState txState; - public Internal.TxContext EnterTxContext(long timestampMicros) + protected ProcedureContextBase( + Identity sender, + ConnectionId? connectionId, + Random random, + Timestamp time + ) { - var timestamp = new Timestamp(timestampMicros); - Timestamp = timestamp; - txContext = - txContext?.WithTimestamp(timestamp) - ?? new Internal.TxContext( + Sender = sender; + ConnectionId = connectionId; + SenderAuth = AuthCtx.BuildFromSystemTables(connectionId, sender); + txState = new( + random, + time, + timestamp => new Internal.TxContext( CreateLocal(), Sender, ConnectionId, timestamp, SenderAuth, - Rng - ); - return txContext; + random + ), + inner => CreateTxContext(inner) + ); } - public void ExitTxContext() => txContext = null; + protected abstract ProcedureTxContextBase CreateTxContext(Internal.TxContext inner); + protected internal abstract LocalBase CreateLocal(); + + public Internal.TxContext EnterTxContext(long timestampMicros) => + txState.EnterTxContext(timestampMicros); + + public void ExitTxContext() => txState.ExitTxContext(); public readonly struct TxOutcome(bool isSuccess, TResult? value, Exception? error) { @@ -82,7 +77,7 @@ public TResult UnwrapOrThrow(Func fallbackFactory) => [Experimental("STDB_UNSTABLE")] public TResult WithTx(Func body) => - TryWithTx(tx => Result.Ok(body(tx))).UnwrapOrThrow(); + txState.WithTx(body); [Experimental("STDB_UNSTABLE")] public TxOutcome TryWithTx( @@ -90,145 +85,24 @@ Func> body ) where TError : Exception { - try - { - var result = RunWithRetry(body); - - return result switch - { - Result.OkR(var value) => TxOutcome.Success(value), - Result.ErrR(var error) => TxOutcome.Failure(error), - _ => throw new InvalidOperationException("Unknown Result variant."), - }; - } - catch (Exception ex) - { - return TxOutcome.Failure(ex); - } - } - - // Private transaction management methods (Rust-like encapsulation) - private long StartMutTx() - { - var status = Internal.FFI.procedure_start_mut_tx(out var micros); - Internal.FFI.ErrnoHelpers.ThrowIfError(status); - return micros; - } - - private void CommitMutTx() - { - var status = Internal.FFI.procedure_commit_mut_tx(); - Internal.FFI.ErrnoHelpers.ThrowIfError(status); - } - - private void AbortMutTx() - { - var status = Internal.FFI.procedure_abort_mut_tx(); - Internal.FFI.ErrnoHelpers.ThrowIfError(status); - } - - private bool CommitMutTxWithRetry(Func retryBody) - { - try - { - CommitMutTx(); - return true; - } - catch (TransactionNotAnonymousException) - { - return false; - } - catch (StdbException) - { - Log.Warn("Committing anonymous transaction failed; retrying once."); - if (retryBody()) - { - CommitMutTx(); - return true; - } - return false; - } - } - - private Result RunWithRetry( - Func> body - ) - where TError : Exception - { - var result = RunOnce(body); - if (result is Result.ErrR) - { - return result; - } - - bool Retry() - { - result = RunOnce(body); - return result is Result.OkR; - } - - if (!CommitMutTxWithRetry(Retry)) - { - return result; - } - - return result; - } - - private Result RunOnce( - Func> body - ) - where TError : Exception - { - var micros = StartMutTx(); - using var guard = new AbortGuard(AbortMutTx); - EnterTxContext(micros); - var txCtx = RequireTxContext(); - - Result result; - try - { - result = body(txCtx); - } - catch (Exception) - { - throw; - } - - if (result is Result.OkR) - { - guard.Disarm(); - return result; - } - - AbortMutTx(); - guard.Disarm(); - return result; - } - - private sealed class AbortGuard(Action abort) : IDisposable - { - private readonly Action abort = abort; - private bool disarmed; - - public void Disarm() => disarmed = true; - - public void Dispose() - { - if (!disarmed) - { - abort(); - } - } + var outcome = txState.TryWithTx(body); + return outcome.IsSuccess + ? TxOutcome.Success(outcome.Value!) + : TxOutcome.Failure( + outcome.Error + ?? new InvalidOperationException("Transaction failed without an error object.") + ); } } -public abstract class ProcedureTxContextBase(Internal.TxContext inner) +public abstract class ProcedureTxContextBase(Internal.TxContext inner) : IRefreshableTxContext { internal Internal.TxContext Inner { get; private set; } = inner; internal void Refresh(Internal.TxContext inner) => Inner = inner; + void IRefreshableTxContext.Refresh(Internal.TxContext inner) => Refresh(inner); + public LocalBase Db => (LocalBase)Inner.Db; public Identity Sender => Inner.Sender; public ConnectionId? ConnectionId => Inner.ConnectionId; diff --git a/crates/bindings-csharp/Runtime/Router.cs b/crates/bindings-csharp/Runtime/Router.cs new file mode 100644 index 00000000000..e146bd5057d --- /dev/null +++ b/crates/bindings-csharp/Runtime/Router.cs @@ -0,0 +1,164 @@ +namespace SpacetimeDB; + +using System; +using System.Collections.Generic; +using Internal; + +public readonly record struct Handler(string FunctionName) { } + +public sealed class Router +{ + internal readonly record struct RouteSpec( + MethodOrAny Method, + string Path, + string HandlerFunction + ); + + private const string AcceptableRoutePathCharsHumanDescription = + "ASCII lowercase letters, digits and `-_~/`"; + + private readonly List routes; + + private Router(List routes) + { + this.routes = routes; + } + + public static Router New() => new([]); + + public Router Get(string path, Handler handler) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Get(default)), path, handler); + + public Router Head(string path, Handler handler) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Head(default)), path, handler); + + public Router Options(string path, Handler handler) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Options(default)), path, handler); + + public Router Put(string path, Handler handler) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Put(default)), path, handler); + + public Router Delete(string path, Handler handler) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Delete(default)), path, handler); + + public Router Post(string path, Handler handler) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Post(default)), path, handler); + + public Router Patch(string path, Handler handler) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Patch(default)), path, handler); + + public Router Any(string path, Handler handler) => + AddRoute(new MethodOrAny.Any(default), path, handler); + + public Router Nest(string path, Router subRouter) + { + AssertValidPath(path); + if (routes.Exists(route => route.Path.StartsWith(path, StringComparison.Ordinal))) + { + throw new ArgumentException( + $"Cannot nest router at `{path}`; existing routes overlap with nested path", + nameof(path) + ); + } + + var merged = CloneRoutes(); + foreach (var route in subRouter.routes) + { + var nestedPath = JoinPaths(path, route.Path); + AddRoute(merged, route.Method, nestedPath, route.HandlerFunction); + } + + return new Router(merged); + } + + public Router Merge(Router otherRouter) + { + var merged = CloneRoutes(); + foreach (var route in otherRouter.routes) + { + AddRoute(merged, route.Method, route.Path, route.HandlerFunction); + } + + return new Router(merged); + } + + internal IReadOnlyList GetRoutes() => routes; + + private Router AddRoute(MethodOrAny method, string path, Handler handler) + { + var merged = CloneRoutes(); + AddRoute(merged, method, path, handler.FunctionName); + return new Router(merged); + } + + private List CloneRoutes() => new(routes); + + private static void AddRoute( + List routes, + MethodOrAny method, + string path, + string handlerFunction + ) + { + AssertValidPath(path); + ArgumentException.ThrowIfNullOrEmpty(handlerFunction); + + var candidate = new RouteSpec(method, path, handlerFunction); + if (routes.Exists(route => RoutesOverlap(route, candidate))) + { + throw new ArgumentException($"Route conflict for `{path}`", nameof(path)); + } + + routes.Add(candidate); + } + + private static string JoinPaths(string prefix, string suffix) + { + if (prefix == "/") + { + return suffix; + } + if (suffix == "/") + { + return prefix; + } + + prefix = prefix.TrimEnd('/'); + suffix = suffix.TrimStart('/'); + return $"{prefix}/{suffix}"; + } + + private static bool RoutesOverlap(RouteSpec a, RouteSpec b) + { + if (!string.Equals(a.Path, b.Path, StringComparison.Ordinal)) + { + return false; + } + + return a.Method is MethodOrAny.Any + || b.Method is MethodOrAny.Any + || Equals(a.Method, b.Method); + } + + private static void AssertValidPath(string path) + { + ArgumentNullException.ThrowIfNull(path); + if (path.Length > 0 && path[0] != '/') + { + throw new ArgumentException($"Route paths must start with `/`: {path}", nameof(path)); + } + foreach (var c in path) + { + if (!CharacterIsAcceptableForRoutePath(c)) + { + throw new ArgumentException( + $"Route paths may contain only {AcceptableRoutePathCharsHumanDescription}: {path}", + nameof(path) + ); + } + } + } + + private static bool CharacterIsAcceptableForRoutePath(char c) => + (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c is '-' or '_' or '~' or '/'; +} diff --git a/crates/bindings-csharp/Runtime/TransactionalContextState.cs b/crates/bindings-csharp/Runtime/TransactionalContextState.cs new file mode 100644 index 00000000000..ef0d72de410 --- /dev/null +++ b/crates/bindings-csharp/Runtime/TransactionalContextState.cs @@ -0,0 +1,199 @@ +namespace SpacetimeDB; + +using System; +using SpacetimeDB.Internal; + +#pragma warning disable STDB_UNSTABLE +internal interface IRefreshableTxContext +{ + void Refresh(Internal.TxContext inner); +} + +internal readonly struct TxOutcomeCore(bool isSuccess, TResult? value, Exception? error) +{ + public bool IsSuccess { get; } = isSuccess; + public TResult? Value { get; } = value; + public Exception? Error { get; } = error; + + public static TxOutcomeCore Success(TResult value) => new(true, value, null); + + public static TxOutcomeCore Failure(Exception error) => new(false, default, error); +} + +internal sealed class TransactionalContextState( + Random random, + Timestamp time, + Func createInitialTxContext, + Func createTxContext +) + where TTxContext : class, IRefreshableTxContext +{ + public Random Rng { get; } = random; + public Timestamp Timestamp { get; private set; } = time; + + private Internal.TxContext? txContext; + private TTxContext? cachedUserTxContext; + + public Internal.TxContext EnterTxContext(long timestampMicros) + { + var timestamp = new Timestamp(timestampMicros); + Timestamp = timestamp; + txContext = txContext?.WithTimestamp(timestamp) ?? createInitialTxContext(timestamp); + return txContext; + } + + public void ExitTxContext() => txContext = null; + + public TTxContext RequireTxContext() + { + var inner = + txContext + ?? throw new InvalidOperationException("Transaction context was not initialised."); + cachedUserTxContext ??= createTxContext(inner); + cachedUserTxContext.Refresh(inner); + return cachedUserTxContext; + } + + public TResult WithTx(Func body) => + TryWithTx(tx => Result.Ok(body(tx))).UnwrapOrThrow(); + + public TxOutcomeCore TryWithTx( + Func> body + ) + where TError : Exception + { + try + { + var result = RunWithRetry(body); + + return result switch + { + Result.OkR(var value) => TxOutcomeCore.Success(value), + Result.ErrR(var error) => TxOutcomeCore.Failure(error), + _ => throw new InvalidOperationException("Unknown Result variant."), + }; + } + catch (Exception ex) + { + return TxOutcomeCore.Failure(ex); + } + } + + private long StartMutTx() + { + var status = FFI.procedure_start_mut_tx(out var micros); + FFI.ErrnoHelpers.ThrowIfError(status); + return micros; + } + + private void CommitMutTx() + { + var status = FFI.procedure_commit_mut_tx(); + FFI.ErrnoHelpers.ThrowIfError(status); + } + + private void AbortMutTx() + { + var status = FFI.procedure_abort_mut_tx(); + FFI.ErrnoHelpers.ThrowIfError(status); + } + + private bool CommitMutTxWithRetry(Func retryBody) + { + try + { + CommitMutTx(); + return true; + } + catch (TransactionNotAnonymousException) + { + return false; + } + catch (StdbException) + { + Log.Warn("Committing anonymous transaction failed; retrying once."); + if (retryBody()) + { + CommitMutTx(); + return true; + } + return false; + } + } + + private Result RunWithRetry( + Func> body + ) + where TError : Exception + { + var result = RunOnce(body); + if (result is Result.ErrR) + { + return result; + } + + bool Retry() + { + result = RunOnce(body); + return result is Result.OkR; + } + + if (!CommitMutTxWithRetry(Retry)) + { + return result; + } + + return result; + } + + private Result RunOnce( + Func> body + ) + where TError : Exception + { + var micros = StartMutTx(); + using var guard = new AbortGuard(AbortMutTx); + EnterTxContext(micros); + var txCtx = RequireTxContext(); + + var result = body(txCtx); + + if (result is Result.OkR) + { + guard.Disarm(); + return result; + } + + AbortMutTx(); + guard.Disarm(); + return result; + } + + private sealed class AbortGuard(Action abort) : IDisposable + { + private readonly Action abort = abort; + private bool disarmed; + + public void Disarm() => disarmed = true; + + public void Dispose() + { + if (!disarmed) + { + abort(); + } + } + } +} + +internal static class TxOutcomeCoreExtensions +{ + public static TResult UnwrapOrThrow(this TxOutcomeCore outcome) => + outcome.IsSuccess + ? outcome.Value! + : throw ( + outcome.Error + ?? new InvalidOperationException("Transaction failed without an error object.") + ); +} +#pragma warning restore STDB_UNSTABLE diff --git a/crates/bindings-csharp/Runtime/bindings.c b/crates/bindings-csharp/Runtime/bindings.c index f2d2d1ca919..57ed816d939 100644 --- a/crates/bindings-csharp/Runtime/bindings.c +++ b/crates/bindings-csharp/Runtime/bindings.c @@ -187,20 +187,25 @@ EXPORT(int16_t, __call_reducer__, &conn_id_0, &conn_id_1, ×tamp, &args, &error); -EXPORT(int16_t, __call_procedure__, - (uint32_t id, - uint64_t sender_0, uint64_t sender_1, uint64_t sender_2, uint64_t sender_3, - uint64_t conn_id_0, uint64_t conn_id_1, - uint64_t timestamp, BytesSource args, BytesSink result_sink), +EXPORT(int16_t, __call_procedure__, + (uint32_t id, + uint64_t sender_0, uint64_t sender_1, uint64_t sender_2, uint64_t sender_3, + uint64_t conn_id_0, uint64_t conn_id_1, + uint64_t timestamp, BytesSource args, BytesSink result_sink), &id, &sender_0, &sender_1, &sender_2, &sender_3, - &conn_id_0, &conn_id_1, - ×tamp, &args, &result_sink); - -EXPORT(int16_t, __call_view__, - (uint32_t id, - uint64_t sender_0, uint64_t sender_1, uint64_t sender_2, uint64_t sender_3, - BytesSource args, BytesSink rows), + &conn_id_0, &conn_id_1, + ×tamp, &args, &result_sink); + +EXPORT(int16_t, __call_http_handler__, + (uint32_t id, uint64_t timestamp, BytesSource request, BytesSource request_body, + BytesSink response_sink, BytesSink response_body_sink), + &id, ×tamp, &request, &request_body, &response_sink, &response_body_sink); + +EXPORT(int16_t, __call_view__, + (uint32_t id, + uint64_t sender_0, uint64_t sender_1, uint64_t sender_2, uint64_t sender_3, + BytesSource args, BytesSink rows), &id, &sender_0, &sender_1, &sender_2, &sender_3, &args, &rows); diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 40abde11f8e..aab3e0bcbe3 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -1,5 +1,5 @@ use regex::Regex; -use spacetimedb_smoketests::{workspace_root, Smoketest}; +use spacetimedb_smoketests::{require_dotnet, workspace_root, Smoketest}; use std::{fs, path::Path}; const MODULE_CODE: &str = r#" @@ -230,13 +230,322 @@ fn router() -> Router { } "#; +const CS_MODULE_CODE: &str = r#" +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE +public static partial class Module +{ + [SpacetimeDB.Table(Accessor = "Entry", Name = "entry", Public = true)] + public partial struct Entry + { + [SpacetimeDB.PrimaryKey] + public ulong Id; + + public string Value; + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse GetSimple(HandlerContext ctx, HttpRequest request) + { + return TextResponse(200, "ok"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse PostInsert(HandlerContext ctx, HttpRequest request) + { + ctx.WithTx((HandlerTxContext tx) => + { + var id = tx.Db.Entry.Count; + tx.Db.Entry.Insert(new Entry { Id = id, Value = "posted" }); + return 0; + }); + return TextResponse(200, "inserted"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse GetCount(HandlerContext ctx, HttpRequest request) + { + var count = ctx.WithTx((HandlerTxContext tx) => tx.Db.Entry.Count); + return TextResponse(200, count.ToString()); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse AnyHandler(HandlerContext ctx, HttpRequest request) + { + return TextResponse(200, "any"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse HeaderEcho(HandlerContext ctx, HttpRequest request) + { + return TextResponse(200, HeaderValueUtf8(request, "x-echo")); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse SetResponseHeader(HandlerContext ctx, HttpRequest request) + { + return new HttpResponse( + 200, + HttpVersion.Http11, + new List { new("x-response", "set") }, + HttpBody.FromString("header-set") + ); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse BodyHandler(HandlerContext ctx, HttpRequest request) + { + return TextResponse(200, "non-empty"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse Teapot(HandlerContext ctx, HttpRequest request) + { + return TextResponse(418, "teapot"); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Get("/get", Handlers.GetSimple) + .Post("/post", Handlers.PostInsert) + .Get("/count", Handlers.GetCount) + .Any("/any", Handlers.AnyHandler) + .Get("/header", Handlers.HeaderEcho) + .Get("/set-header", Handlers.SetResponseHeader) + .Get("/body", Handlers.BodyHandler) + .Get("/teapot", Handlers.Teapot); + + private static string HeaderValueUtf8(HttpRequest request, string headerName) + { + foreach (var header in request.Headers) + { + if (string.Equals(header.Name, headerName, StringComparison.OrdinalIgnoreCase)) + { + return Encoding.UTF8.GetString(header.Value); + } + } + return string.Empty; + } + + private static HttpResponse TextResponse(ushort statusCode, string body) => + new( + statusCode, + HttpVersion.Http11, + new List(), + HttpBody.FromString(body) + ); +} +"#; + +const CS_EXAMPLE_MODULE_CODE: &str = r#" +using System.Collections.Generic; +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE +public static partial class Module +{ + [SpacetimeDB.Table(Accessor = "Data", Name = "data", Public = true)] + public partial struct Data + { + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + public ulong Id; + + public byte[] Body; + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse Insert(HandlerContext ctx, HttpRequest request) + { + var body = request.Body.ToBytes(); + var id = ctx.WithTx((HandlerTxContext tx) => tx.Db.Data.Insert(new Data { Id = 0, Body = body }).Id); + return TextResponse(200, id.ToString()); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse Retrieve(HandlerContext ctx, HttpRequest request) + { + var idText = request.Uri.Split("id=", 2)[1]; + var id = ulong.Parse(idText); + var body = ctx.WithTx((HandlerTxContext tx) => tx.Db.Data.Id.Find(id)?.Body); + + if (body is not null) + { + return BytesResponse(200, body); + } + + return new HttpResponse(404, HttpVersion.Http11, new List(), HttpBody.Empty); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Post("/insert", Handlers.Insert) + .Get("/retrieve", Handlers.Retrieve); + + private static HttpResponse BytesResponse(ushort statusCode, byte[] body) => + new(statusCode, HttpVersion.Http11, new List(), new HttpBody(body)); + + private static HttpResponse TextResponse(ushort statusCode, string body) => + new(statusCode, HttpVersion.Http11, new List(), HttpBody.FromString(body)); +} +"#; + +const CS_STRICT_ROOT_ROUTING_MODULE_CODE: &str = r#" +using System.Collections.Generic; +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.HttpHandler] + public static HttpResponse EmptyRoot(HandlerContext ctx, HttpRequest request) + { + return TextResponse("empty"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse SlashRoot(HandlerContext ctx, HttpRequest request) + { + return TextResponse("slash"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse Foo(HandlerContext ctx, HttpRequest request) + { + return TextResponse("foo"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse FooSlash(HandlerContext ctx, HttpRequest request) + { + return TextResponse("foo-slash"); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Get("", Handlers.EmptyRoot) + .Get("/", Handlers.SlashRoot) + .Get("/foo", Handlers.Foo) + .Get("/foo/", Handlers.FooSlash); + + private static HttpResponse TextResponse(string body) => + new(200, HttpVersion.Http11, new List(), HttpBody.FromString(body)); +} +"#; + +const CS_STRICT_NON_ROOT_ROUTING_MODULE_CODE: &str = r#" +using System.Collections.Generic; +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.HttpHandler] + public static HttpResponse Foo(HandlerContext ctx, HttpRequest request) + { + return TextResponse("foo"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse FooSlash(HandlerContext ctx, HttpRequest request) + { + return TextResponse("foo-slash"); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Get("/foo", Handlers.Foo) + .Get("/foo/", Handlers.FooSlash); + + private static HttpResponse TextResponse(string body) => + new(200, HttpVersion.Http11, new List(), HttpBody.FromString(body)); +} +"#; + +const CS_FULL_URI_MODULE_CODE: &str = r#" +using System.Collections.Generic; +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.HttpHandler] + public static HttpResponse EchoUri(HandlerContext ctx, HttpRequest request) + { + return new HttpResponse( + 200, + HttpVersion.Http11, + new List(), + HttpBody.FromString(request.Uri) + ); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New().Get("/echo-uri", Handlers.EchoUri); +} +"#; + +const CS_HANDLE_REQUEST_BODY_MODULE_CODE: &str = r#" +using System; +using System.Collections.Generic; +using System.Text; +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.HttpHandler] + public static HttpResponse ReverseBytes(HandlerContext ctx, HttpRequest request) + { + var reversed = request.Body.ToBytes(); + Array.Reverse(reversed); + return BytesResponse(200, reversed); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse ReverseWords(HandlerContext ctx, HttpRequest request) + { + string body; + try + { + body = new UTF8Encoding(false, true).GetString(request.Body.ToBytes()); + } + catch (DecoderFallbackException) + { + return TextResponse(400, "request body must be valid UTF-8"); + } + + var reversed = string.Join(" ", body.Split(' ').Reverse()); + return TextResponse(200, reversed); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Post("/reverse-bytes", Handlers.ReverseBytes) + .Post("/reverse-words", Handlers.ReverseWords); + + private static HttpResponse BytesResponse(ushort statusCode, byte[] body) => + new(statusCode, HttpVersion.Http11, new List(), new HttpBody(body)); + + private static HttpResponse TextResponse(ushort statusCode, string body) => + new(statusCode, HttpVersion.Http11, new List(), HttpBody.FromString(body)); +} +"#; + const NO_SUCH_ROUTE_BODY: &str = "Database has not registered a handler for this route"; -fn extract_rust_code_blocks(doc_path: &Path) -> String { +fn extract_code_blocks(doc_path: &Path, regex_src: &str, language_name: &str) -> String { let doc = fs::read_to_string(doc_path).unwrap_or_else(|e| panic!("failed to read {}: {e}", doc_path.display())); let doc = doc.replace("\r\n", "\n"); - let re = Regex::new(r"```rust\n([\s\S]*?)\n```").expect("regex should compile"); + let re = Regex::new(regex_src).expect("regex should compile"); let blocks: Vec<_> = re .captures_iter(&doc) .map(|cap| cap.get(1).expect("capture group should exist").as_str().to_string()) @@ -244,19 +553,36 @@ fn extract_rust_code_blocks(doc_path: &Path) -> String { assert!( !blocks.is_empty(), - "expected at least one rust code block in {}", + "expected at least one {} code block in {}", + language_name, doc_path.display() ); blocks.join("\n\n") } -#[test] -fn http_routes_end_to_end() { - let test = Smoketest::builder().module_code(MODULE_CODE).build(); - let identity = test.database_identity.as_ref().expect("database identity missing"); +fn rust_http_test(module_code: &str) -> (Smoketest, String) { + let test = Smoketest::builder().module_code(module_code).build(); + let identity = test + .database_identity + .as_ref() + .expect("database identity missing") + .clone(); + (test, identity) +} + +fn csharp_http_test(name: &str, module_code: &str) -> (Smoketest, String) { + let mut test = Smoketest::builder().autopublish(false).build(); + let identity = test.publish_csharp_module_source(name, name, module_code).unwrap(); + (test, identity) +} + +fn route_base(server_url: &str, identity: &str) -> String { + format!("{server_url}/v1/database/{identity}/route") +} - let base = format!("{}/v1/database/{}/route", test.server_url, identity); +fn assert_http_routes_end_to_end(server_url: &str, identity: &str) { + let base = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let resp = client.get(format!("{base}/get")).send().expect("get failed"); @@ -311,10 +637,7 @@ fn http_routes_end_to_end() { assert_eq!(resp.text().expect("missing route body"), NO_SUCH_ROUTE_BODY); let resp = client - .get(format!( - "{}/v1/database/{}/schema?version=10", - test.server_url, identity - )) + .get(format!("{server_url}/v1/database/{identity}/schema?version=10")) .header("authorization", "Bearer not-a-jwt") .send() .expect("schema request failed"); @@ -328,12 +651,8 @@ fn http_routes_end_to_end() { assert!(resp.status().is_success()); } -#[test] -fn http_routes_pr_example_round_trip() { - let test = Smoketest::builder().module_code(EXAMPLE_MODULE_CODE).build(); - let identity = test.database_identity.as_ref().expect("database identity missing"); - - let base = format!("{}/v1/database/{}/route", test.server_url, identity); +fn assert_http_routes_pr_example_round_trip(server_url: &str, identity: &str) { + let base = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let payload = b"hello from the PR example".to_vec(); @@ -368,14 +687,8 @@ fn http_routes_pr_example_round_trip() { assert!(resp.status().is_server_error()); } -#[test] -fn http_routes_are_strict_for_non_root_paths() { - let test = Smoketest::builder() - .module_code(STRICT_NON_ROOT_ROUTING_MODULE_CODE) - .build(); - let identity = test.database_identity.as_ref().expect("database identity missing"); - - let base = format!("{}/v1/database/{}/route", test.server_url, identity); +fn assert_http_routes_are_strict_for_non_root_paths(server_url: &str, identity: &str) { + let base = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let resp = client.get(format!("{base}/foo")).send().expect("foo failed"); @@ -398,14 +711,8 @@ fn http_routes_are_strict_for_non_root_paths() { assert_eq!(resp.text().expect("double slash foo body"), NO_SUCH_ROUTE_BODY); } -#[test] -fn http_routes_are_strict_for_root_paths() { - let test = Smoketest::builder() - .module_code(STRICT_ROOT_ROUTING_MODULE_CODE) - .build(); - let identity = test.database_identity.as_ref().expect("database identity missing"); - - let base = format!("{}/v1/database/{}/route", test.server_url, identity); +fn assert_http_routes_are_strict_for_root_paths(server_url: &str, identity: &str) { + let base = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let resp = client.get(base.clone()).send().expect("empty root failed"); @@ -417,12 +724,8 @@ fn http_routes_are_strict_for_root_paths() { assert_eq!(resp.text().expect("slash root body"), "slash"); } -#[test] -fn http_handler_observes_full_external_uri() { - let test = Smoketest::builder().module_code(FULL_URI_MODULE_CODE).build(); - let identity = test.database_identity.as_ref().expect("database identity missing"); - - let base = format!("{}/v1/database/{}/route", test.server_url, identity); +fn assert_http_handler_observes_full_external_uri(server_url: &str, identity: &str) { + let base = route_base(server_url, identity); let url = format!("{base}/echo-uri?alpha=beta"); let client = reqwest::blocking::Client::new(); @@ -431,14 +734,8 @@ fn http_handler_observes_full_external_uri() { assert_eq!(resp.text().expect("echo-uri body"), url); } -#[test] -fn handle_request_body() { - let test = Smoketest::builder() - .module_code(HANDLE_REQUEST_BODY_MODULE_CODE) - .build(); - let identity = test.database_identity.as_ref().expect("database identity missing"); - - let base = format!("{}/v1/database/{}/route", test.server_url, identity); +fn assert_handle_request_body(server_url: &str, identity: &str) { + let base = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let resp = client @@ -502,11 +799,94 @@ fn handle_request_body() { ); } +#[test] +fn http_routes_end_to_end() { + let (test, identity) = rust_http_test(MODULE_CODE); + assert_http_routes_end_to_end(&test.server_url, &identity); +} + +#[test] +fn http_routes_pr_example_round_trip() { + let (test, identity) = rust_http_test(EXAMPLE_MODULE_CODE); + assert_http_routes_pr_example_round_trip(&test.server_url, &identity); +} + +#[test] +fn http_routes_are_strict_for_non_root_paths() { + let (test, identity) = rust_http_test(STRICT_NON_ROOT_ROUTING_MODULE_CODE); + assert_http_routes_are_strict_for_non_root_paths(&test.server_url, &identity); +} + +#[test] +fn http_routes_are_strict_for_root_paths() { + let (test, identity) = rust_http_test(STRICT_ROOT_ROUTING_MODULE_CODE); + assert_http_routes_are_strict_for_root_paths(&test.server_url, &identity); +} + +#[test] +fn http_handler_observes_full_external_uri() { + let (test, identity) = rust_http_test(FULL_URI_MODULE_CODE); + assert_http_handler_observes_full_external_uri(&test.server_url, &identity); +} + +#[test] +fn handle_request_body() { + let (test, identity) = rust_http_test(HANDLE_REQUEST_BODY_MODULE_CODE); + assert_handle_request_body(&test.server_url, &identity); +} + +#[test] +fn csharp_http_routes_end_to_end() { + require_dotnet!(); + let (test, identity) = csharp_http_test("http-routes-csharp-basic", CS_MODULE_CODE); + assert_http_routes_end_to_end(&test.server_url, &identity); +} + +#[test] +fn csharp_http_routes_pr_example_round_trip() { + require_dotnet!(); + let (test, identity) = csharp_http_test("http-routes-csharp-example", CS_EXAMPLE_MODULE_CODE); + assert_http_routes_pr_example_round_trip(&test.server_url, &identity); +} + +#[test] +fn csharp_http_routes_are_strict_for_non_root_paths() { + require_dotnet!(); + let (test, identity) = csharp_http_test( + "http-routes-csharp-strict-non-root", + CS_STRICT_NON_ROOT_ROUTING_MODULE_CODE, + ); + assert_http_routes_are_strict_for_non_root_paths(&test.server_url, &identity); +} + +#[test] +fn csharp_http_routes_are_strict_for_root_paths() { + require_dotnet!(); + let (test, identity) = csharp_http_test("http-routes-csharp-strict-root", CS_STRICT_ROOT_ROUTING_MODULE_CODE); + assert_http_routes_are_strict_for_root_paths(&test.server_url, &identity); +} + +#[test] +fn csharp_http_handler_observes_full_external_uri() { + require_dotnet!(); + let (test, identity) = csharp_http_test("http-routes-csharp-full-uri", CS_FULL_URI_MODULE_CODE); + assert_http_handler_observes_full_external_uri(&test.server_url, &identity); +} + +#[test] +fn csharp_handle_request_body() { + require_dotnet!(); + let (test, identity) = csharp_http_test("http-routes-csharp-request-body", CS_HANDLE_REQUEST_BODY_MODULE_CODE); + assert_handle_request_body(&test.server_url, &identity); +} + /// Validates the Rust example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. #[test] fn http_handlers_tutorial_say_hello_route_works() { - let module_code = extract_rust_code_blocks( + let module_code = extract_code_blocks( &workspace_root().join("docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md"), + r"```rust\n([\s\S]*?)\n```", + "rust", ); let test = Smoketest::builder().module_code(&module_code).build(); let identity = test.database_identity.as_ref().expect("database identity missing"); @@ -518,3 +898,22 @@ fn http_handlers_tutorial_say_hello_route_works() { assert!(resp.status().is_success()); assert_eq!(resp.text().expect("say-hello body"), "Hello!"); } + +/// Validates the C# example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. +#[test] +fn csharp_http_handlers_tutorial_say_hello_route_works() { + require_dotnet!(); + let module_code = extract_code_blocks( + &workspace_root().join("docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md"), + r"```csharp\n([\s\S]*?)\n```", + "csharp", + ); + let (test, identity) = csharp_http_test("http-handlers-docs-csharp", &module_code); + + let url = format!("{}/v1/database/{}/route/say-hello", test.server_url, identity); + let client = reqwest::blocking::Client::new(); + + let resp = client.get(&url).send().expect("say-hello failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("say-hello body"), "Hello!"); +} diff --git a/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md b/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md index 67589fad0ba..aab6d67690f 100644 --- a/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md +++ b/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md @@ -16,6 +16,29 @@ External clients can make HTTP requests to routes nested under [`/v1/database/:n ## Defining HTTP Handlers + + +Define an HTTP handler with `spacetimedb.httpHandler`. + +The function must accept exactly two arguments: + +1. A `HandlerContext`. +2. A `Request`. + +The function must return a `SyncResponse`. + +```typescript +import { schema, SyncResponse } from "spacetimedb/server"; + +const spacetimedb = schema({}); +export default spacetimedb; + +export const say_hello = spacetimedb.httpHandler((_ctx, _req) => { + return new SyncResponse("Hello!"); +}); +``` + + Because HTTP handlers are unstable, Rust modules that define them must opt in to the `unstable` feature in their `Cargo.toml`: @@ -43,6 +66,40 @@ fn say_hello(_ctx: &mut HandlerContext, _req: Request) -> Response { } ``` + + + +HTTP handlers in C# are currently unstable. To use them, add `#pragma warning disable STDB_UNSTABLE` at the top of your file. + +Define an HTTP handler by annotating a method with `[SpacetimeDB.HttpHandler]`. + +The method must accept exactly two arguments: + +1. A `SpacetimeDB.HandlerContext`. +2. A `SpacetimeDB.HttpRequest`. + +The method must return a `SpacetimeDB.HttpResponse`. + +```csharp +using System.Collections.Generic; +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE +public static partial class Module +{ + [SpacetimeDB.HttpHandler] + public static HttpResponse SayHello(HandlerContext ctx, HttpRequest request) + { + return new HttpResponse( + 200, + HttpVersion.Http11, + new List(), + HttpBody.FromString("Hello!") + ); + } +} +``` + @@ -51,6 +108,26 @@ fn say_hello(_ctx: &mut HandlerContext, _req: Request) -> Response { Once you've [defined an HTTP handler](#defining-http-handlers), you must register it to a route in order to make it reachable for requests. + + +All routes exposed by your module are declared in a `Router`. Register the `Router` for your database by passing it to `spacetimedb.httpRouter`. + +```typescript +import { Router } from "spacetimedb/server"; + +export const router = spacetimedb.httpRouter( + new Router() + .get("/say-hello", say_hello) +); +``` + +Add routes within a router with the `get`, `head`, `options`, `put`, `delete`, `post`, `patch` and `any` methods, which register an HTTP handler for that HTTP method at a given path. + +Nest routers with `router.nest(prefix, subRouter)`, which causes `subRouter` to handle routing for all paths that start with `prefix`. + +Combine routers with `router.merge(otherRouter)`, which combines both routers. + + All routes exposed by your module are declared in a `spacetimedb::http::Router`. Register the `Router` for your database by returning it from a function annotated with `#[spacetimedb::http::router]`. @@ -71,6 +148,27 @@ Nest routers with `router.nest(prefix, sub_router)`, which causes `sub_router` t Combine routers with `router.merge(other_router)`, which combines both routers. + + + +All routes exposed by your module are declared in a `SpacetimeDB.Router`. Register the `Router` for your database by returning it from a method annotated with `[SpacetimeDB.HttpRouter]`. + +```csharp +public static partial class Module +{ + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Get("/say-hello", Handlers.SayHello); +} +``` + +Add routes within a router with the `Get`, `Head`, `Options`, `Put`, `Delete`, `Post`, `Patch` and `Any` methods, which register an HTTP handler for that HTTP method at a given path. + +Nest routers with `router.Nest(prefix, subRouter)`, which causes `subRouter` to handle routing for all paths that start with `prefix`. + +Combine routers with `router.Merge(otherRouter)`, which combines both routers. + From ac165127c8b079fff1adfbdda860e1a48367909b Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 22 May 2026 13:17:36 -0400 Subject: [PATCH 39/47] fmt --- crates/schema/src/def.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index 2db19f5bf44..c3b2b836cbf 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -32,9 +32,10 @@ use spacetimedb_data_structures::error_stream::{CollectAllErrors, CombineErrors, use spacetimedb_data_structures::map::{Equivalent, HashMap}; use spacetimedb_lib::db::raw_def; use spacetimedb_lib::db::raw_def::v10::{ - ExplicitNames, RawConstraintDefV10, RawHttpHandlerDefV10, RawHttpRouteDefV10, RawIndexDefV10, RawLifeCycleReducerDefV10, RawModuleDefV10, - RawModuleDefV10Section, RawProcedureDefV10, RawReducerDefV10, RawRowLevelSecurityDefV10, RawScheduleDefV10, - RawScopedTypeNameV10, RawSequenceDefV10, RawTableDefV10, RawTypeDefV10, RawViewDefV10, + ExplicitNames, RawConstraintDefV10, RawHttpHandlerDefV10, RawHttpRouteDefV10, RawIndexDefV10, + RawLifeCycleReducerDefV10, RawModuleDefV10, RawModuleDefV10Section, RawProcedureDefV10, RawReducerDefV10, + RawRowLevelSecurityDefV10, RawScheduleDefV10, RawScopedTypeNameV10, RawSequenceDefV10, RawTableDefV10, + RawTypeDefV10, RawViewDefV10, }; use spacetimedb_lib::db::raw_def::v9::{ Lifecycle, RawColumnDefaultValueV9, RawConstraintDataV9, RawConstraintDefV9, RawIndexAlgorithm, RawIndexDefV9, From 6d4c3236b9bdade45939e79efd91e07d8a90d1ea Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 22 May 2026 14:09:58 -0400 Subject: [PATCH 40/47] Fix some merge mistakes --- crates/schema/src/def.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index c3b2b836cbf..baae44ed76f 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -32,7 +32,7 @@ use spacetimedb_data_structures::error_stream::{CollectAllErrors, CombineErrors, use spacetimedb_data_structures::map::{Equivalent, HashMap}; use spacetimedb_lib::db::raw_def; use spacetimedb_lib::db::raw_def::v10::{ - ExplicitNames, RawConstraintDefV10, RawHttpHandlerDefV10, RawHttpRouteDefV10, RawIndexDefV10, + ExplicitNames, MethodOrAny, RawConstraintDefV10, RawHttpHandlerDefV10, RawHttpRouteDefV10, RawIndexDefV10, RawLifeCycleReducerDefV10, RawModuleDefV10, RawModuleDefV10Section, RawProcedureDefV10, RawReducerDefV10, RawRowLevelSecurityDefV10, RawScheduleDefV10, RawScopedTypeNameV10, RawSequenceDefV10, RawTableDefV10, RawTypeDefV10, RawViewDefV10, From 6b3795457999aed5b1b26d872ddaea5ea284063f Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 22 May 2026 14:10:15 -0400 Subject: [PATCH 41/47] And some more merge mistakes --- crates/schema/src/def/validate/v10.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index d2e899f9cad..5ea6370f2d0 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -290,7 +290,7 @@ pub fn validate(def: RawModuleDefV10) -> Result { .map(|rls| (rls.sql.clone(), rls.to_owned())) .collect(); - let (tables, types, reducers, procedures, views, http_handlers, http_routes, mounts) = + let (tables, types, reducers, procedures, views, http_handlers, http_routes) = tables_types_reducers_procedures_views .map( |(tables, types, reducers, procedures, views, (http_handlers, http_routes))| { @@ -1004,9 +1004,7 @@ mod tests { use itertools::Itertools; use spacetimedb_data_structures::expect_error_matching; - use spacetimedb_lib::db::raw_def::v10::{ - CaseConversionPolicy, MethodOrAny, RawModuleDefV10, RawModuleDefV10Builder, RawModuleDefV10Section, - }; + use spacetimedb_lib::db::raw_def::v10::{CaseConversionPolicy, MethodOrAny, RawModuleDefV10Builder}; use spacetimedb_lib::db::raw_def::v9::{btree, direct, hash}; use spacetimedb_lib::db::raw_def::*; use spacetimedb_lib::http::Method as HttpMethod; From c66379dd6e3eb194ecd0547f83810f502dade9c8 Mon Sep 17 00:00:00 2001 From: JasonAtClockwork Date: Fri, 22 May 2026 11:27:52 -0700 Subject: [PATCH 42/47] Regen module def for C# --- .../Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs index 9282c607714..6706fb278f0 100644 --- a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs @@ -19,6 +19,7 @@ public partial record RawModuleDefV10Section : SpacetimeDB.TaggedEnum<( System.Collections.Generic.List LifeCycleReducers, System.Collections.Generic.List RowLevelSecurity, SpacetimeDB.CaseConversionPolicy CaseConversionPolicy, + ExplicitNames ExplicitNames, System.Collections.Generic.List HttpHandlers, System.Collections.Generic.List HttpRoutes )>; From 7f37ff6992ebb1e32e3b7e41c1e0cab3b9627a50 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 22 May 2026 17:49:01 -0400 Subject: [PATCH 43/47] TypeScript HTTP handlers (#4931) Based on #4636 . # Description of Changes This commit adds host support for registering HTTP handlers in V8 modules, and a minimal draft of TypeScript bindings support for the same. The TypeScript bindings support is fully vibe-coded and unreviewed, and is present only to allow a new smoketest, which is added to the `http_routes` suite. The host changes were also AI-assisted, but I reviewed and polished them. # API and ABI breaking changes Adds new TypeScript "ABI." Also adds a new API. # Expected complexity level and risk 2: pretty simple extensions to TypeScript execution, which largely mirror existing `call_procedure` machinery. # Testing - [x] New smoketest. --------- Co-authored-by: Jason Larabie --- .../src/lib/autogen/types.ts | 29 ++ .../bindings-typescript/src/lib/http_types.ts | 8 - crates/bindings-typescript/src/lib/schema.ts | 14 + crates/bindings-typescript/src/sdk/logger.ts | 2 +- .../src/server/http.test-d.ts | 80 ++++ crates/bindings-typescript/src/server/http.ts | 16 +- .../src/server/http_handlers.ts | 403 ++++++++++++++++++ .../src/server/http_internal.ts | 157 +------ .../src/server/http_shared.ts | 186 ++++++++ .../bindings-typescript/src/server/index.ts | 11 + .../src/server/procedures.ts | 38 +- .../bindings-typescript/src/server/runtime.ts | 138 +++++- .../bindings-typescript/src/server/schema.ts | 73 +++- .../bindings-typescript/src/server/sys.d.ts | 7 + .../bindings-typescript/src/server/views.ts | 1 + .../tests/http_handlers.test.ts | 234 ++++++++++ .../tests/http_headers.test.ts | 2 +- crates/client-api/src/routes/database.rs | 8 - crates/core/src/host/module_host.rs | 8 +- crates/core/src/host/v8/mod.rs | 60 ++- crates/core/src/host/v8/syscall/common.rs | 60 ++- crates/core/src/host/v8/syscall/hooks.rs | 6 + crates/core/src/host/v8/syscall/mod.rs | 2 +- crates/core/src/host/v8/syscall/v2.rs | 14 + .../tests/smoketests/http_routes.rs | 271 +++++++++++- 25 files changed, 1606 insertions(+), 222 deletions(-) delete mode 100644 crates/bindings-typescript/src/lib/http_types.ts create mode 100644 crates/bindings-typescript/src/server/http.test-d.ts create mode 100644 crates/bindings-typescript/src/server/http_handlers.ts create mode 100644 crates/bindings-typescript/src/server/http_shared.ts create mode 100644 crates/bindings-typescript/tests/http_handlers.test.ts diff --git a/crates/bindings-typescript/src/lib/autogen/types.ts b/crates/bindings-typescript/src/lib/autogen/types.ts index 0ce535eca0f..c0855af29bf 100644 --- a/crates/bindings-typescript/src/lib/autogen/types.ts +++ b/crates/bindings-typescript/src/lib/autogen/types.ts @@ -158,6 +158,15 @@ export const Lifecycle = __t.enum('Lifecycle', { }); export type Lifecycle = __Infer; +// The tagged union or sum type for the algebraic type `MethodOrAny`. +export const MethodOrAny = __t.enum('MethodOrAny', { + Any: __t.unit(), + get Method() { + return HttpMethod; + }, +}); +export type MethodOrAny = __Infer; + // The tagged union or sum type for the algebraic type `MiscModuleExport`. export const MiscModuleExport = __t.enum('MiscModuleExport', { get TypeAlias() { @@ -239,6 +248,20 @@ export const RawConstraintDefV9 = __t.object('RawConstraintDefV9', { }); export type RawConstraintDefV9 = __Infer; +export const RawHttpHandlerDefV10 = __t.object('RawHttpHandlerDefV10', { + sourceName: __t.string(), +}); +export type RawHttpHandlerDefV10 = __Infer; + +export const RawHttpRouteDefV10 = __t.object('RawHttpRouteDefV10', { + handlerFunction: __t.string(), + get method() { + return MethodOrAny; + }, + path: __t.string(), +}); +export type RawHttpRouteDefV10 = __Infer; + // The tagged union or sum type for the algebraic type `RawIndexAlgorithm`. export const RawIndexAlgorithm = __t.enum('RawIndexAlgorithm', { BTree: __t.array(__t.u16()), @@ -358,6 +381,12 @@ export const RawModuleDefV10Section = __t.enum('RawModuleDefV10Section', { get ExplicitNames() { return ExplicitNames; }, + get HttpHandlers() { + return __t.array(RawHttpHandlerDefV10); + }, + get HttpRoutes() { + return __t.array(RawHttpRouteDefV10); + }, }); export type RawModuleDefV10Section = __Infer; diff --git a/crates/bindings-typescript/src/lib/http_types.ts b/crates/bindings-typescript/src/lib/http_types.ts deleted file mode 100644 index 914a6f789ac..00000000000 --- a/crates/bindings-typescript/src/lib/http_types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - HttpHeaderPair, - HttpHeaders, - HttpMethod, - HttpRequest, - HttpResponse, - HttpVersion, -} from './autogen/types'; diff --git a/crates/bindings-typescript/src/lib/schema.ts b/crates/bindings-typescript/src/lib/schema.ts index be9edc9e113..ab480c93db5 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -195,6 +195,8 @@ export class ModuleContext { procedures: [], views: [], lifeCycleReducers: [], + httpHandlers: [], + httpRoutes: [], caseConversionPolicy: { tag: 'SnakeCase' }, explicitNames: { entries: [], @@ -227,6 +229,18 @@ export class ModuleContext { value: module.lifeCycleReducers, } ); + push( + module.httpHandlers && { + tag: 'HttpHandlers', + value: module.httpHandlers, + } + ); + push( + module.httpRoutes && { + tag: 'HttpRoutes', + value: module.httpRoutes, + } + ); push( module.rowLevelSecurity && { tag: 'RowLevelSecurity', diff --git a/crates/bindings-typescript/src/sdk/logger.ts b/crates/bindings-typescript/src/sdk/logger.ts index 860292cc26c..7374e8b3510 100644 --- a/crates/bindings-typescript/src/sdk/logger.ts +++ b/crates/bindings-typescript/src/sdk/logger.ts @@ -73,7 +73,7 @@ const SENSITIVE_KEYS = new Set([ ]); export const stringify = (value: unknown): string | undefined => - ssStringify(value, (key, current) => { + ssStringify(value, (key: string, current: unknown) => { if (SENSITIVE_KEYS.has(key)) { return '[REDACTED]'; } diff --git a/crates/bindings-typescript/src/server/http.test-d.ts b/crates/bindings-typescript/src/server/http.test-d.ts new file mode 100644 index 00000000000..80299ef6a85 --- /dev/null +++ b/crates/bindings-typescript/src/server/http.test-d.ts @@ -0,0 +1,80 @@ +import { table } from '../lib/table'; +import t from '../lib/type_builders'; +import { + type HandlerContext, + Request, + SyncResponse, + Router, + schema, +} from './index'; + +const person = table( + {}, + { + id: t.u32().primaryKey(), + name: t.string(), + } +); + +const stdb = schema({ person }); + +const hello = stdb.httpHandler((ctx, req) => { + void ctx.identity; + void ctx.random; + req.text(); + req.json(); + + ctx.withTx(tx => { + tx.db.person.insert({ id: 1, name: 'alice' }); + }); + + return new SyncResponse('hello', { + headers: { 'content-type': 'text/plain' }, + status: 200, + }); +}); + +const _typedHello: (ctx: HandlerContext, req: Request) => SyncResponse = ( + ctx, + req +) => { + void ctx.timestamp; + return new SyncResponse(req.text()); +}; + +const named = stdb.httpHandler({ name: 'hello' }, (_ctx, _req) => { + return new SyncResponse('named'); +}); + +const routes = stdb.httpRouter( + new Router() + .get('/hello', hello) + .get('/named', named) + .post('/hello-post', hello) + .nest('/api', new Router().any('/v1', hello)) + .merge(new Router().get('', hello)) +); + +void routes; + +// @ts-expect-error handlers must return SyncResponse +stdb.httpHandler((_ctx, _req) => 123); + +// @ts-expect-error handlers must take HandlerContext as the first argument +stdb.httpHandler((_ctx: number, _req: Request) => new SyncResponse('bad')); + +// @ts-expect-error handlers must take a Request as the second argument +stdb.httpHandler((_ctx, _req: number) => new SyncResponse('bad')); + +stdb.httpHandler((ctx, req) => { + // @ts-expect-error HTTP handlers do not expose sender directly + void ctx.sender; + // @ts-expect-error HTTP handlers do not expose connectionId directly + void ctx.connectionId; + // @ts-expect-error HTTP handlers do not expose db directly + void ctx.db; + return new SyncResponse(req.text()); +}); + +// @ts-expect-error routers must reference exported http handlers, not raw functions +new Router().get('/raw', (_ctx, _req) => new SyncResponse('bad')); diff --git a/crates/bindings-typescript/src/server/http.ts b/crates/bindings-typescript/src/server/http.ts index 1a2595ed4f1..62f68906f1b 100644 --- a/crates/bindings-typescript/src/server/http.ts +++ b/crates/bindings-typescript/src/server/http.ts @@ -1,2 +1,14 @@ -export { Headers, SyncResponse } from './http_internal'; -export type { BodyInit, HeadersInit, ResponseInit } from './http_internal'; +export { + type BodyInit, + type HeadersInit, + type RequestInit, + type ResponseInit, + Headers, + Request, + SyncResponse, + Router, + type HandlerContext, + type HandlerFn, + type HttpHandlerExport, + type HttpHandlerOpts, +} from './http_handlers'; diff --git a/crates/bindings-typescript/src/server/http_handlers.ts b/crates/bindings-typescript/src/server/http_handlers.ts new file mode 100644 index 00000000000..2772ca88752 --- /dev/null +++ b/crates/bindings-typescript/src/server/http_handlers.ts @@ -0,0 +1,403 @@ +import type { Identity } from '../lib/identity'; +import type { + HttpMethod, + HttpVersion, + MethodOrAny, +} from '../lib/autogen/types'; +import type { UntypedSchemaDef } from '../lib/schema'; +import type { Timestamp } from '../lib/timestamp'; +import type { Uuid } from '../lib/uuid'; +import type { TransactionCtx } from './procedures'; +import type { HttpClient } from './http_internal'; +import type { Random } from './rng'; +import { + exportContext, + registerExport, + type ModuleExport, + type SchemaInner, +} from './schema'; +import { + Headers, + makeResponse, + SyncResponse, + textDecoder, + textEncoder, + type BodyInit, + type HeadersInit, + type ResponseInit, +} from './http_shared'; + +export { Headers }; +export { SyncResponse }; +export type { BodyInit, HeadersInit, ResponseInit }; +export { makeResponse }; +export const httpHandlerFn = Symbol('SpacetimeDB.httpHandlerFn'); + +export interface RequestInit { + body?: BodyInit | null; + headers?: HeadersInit; + method?: string; + version?: HttpVersion; +} + +type RequestInner = { + headers: Headers; + method: string; + uri: string; + version: HttpVersion; +}; + +type RouteSpec = { + handler: HttpHandlerExport; + method: MethodOrAny; + path: string; +}; + +const ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION = + 'ASCII lowercase letters, digits and `-_~/`'; + +export const makeRequest = Symbol('makeRequest'); + +function coerceRequestBody(body?: BodyInit | null): string | Uint8Array | null { + if (body == null) { + return null; + } + if (typeof body === 'string') { + return body; + } + return new Uint8Array(body as any); +} + +function requestBodyToBytes(body: string | Uint8Array | null): Uint8Array { + if (body == null) { + return new Uint8Array(); + } + if (typeof body === 'string') { + return textEncoder.encode(body); + } + return body; +} + +function requestBodyToText(body: string | Uint8Array | null): string { + if (body == null) { + return ''; + } + if (typeof body === 'string') { + return body; + } + return textDecoder.decode(body); +} + +function characterIsAcceptableForRoutePath(c: string) { + return ( + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c === '-' || + c === '_' || + c === '~' || + c === '/' + ); +} + +function assertValidPath(path: string) { + if (path !== '' && !path.startsWith('/')) { + throw new TypeError(`Route paths must start with \`/\`: ${path}`); + } + if (![...path].every(characterIsAcceptableForRoutePath)) { + throw new TypeError( + `Route paths may contain only ${ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION}: ${path}` + ); + } +} + +function routesOverlap(a: RouteSpec, b: RouteSpec) { + const methodsMatch = (left: HttpMethod, right: HttpMethod) => { + if (left.tag !== right.tag) { + return false; + } + if (left.tag === 'Extension' && right.tag === 'Extension') { + return left.value === right.value; + } + return true; + }; + + return ( + a.path === b.path && + (a.method.tag === 'Any' || + b.method.tag === 'Any' || + (a.method.tag === 'Method' && + b.method.tag === 'Method' && + methodsMatch(a.method.value, b.method.value))) + ); +} + +function joinPaths(prefix: string, suffix: string) { + if (prefix === '/') { + return suffix; + } + if (suffix === '/') { + return prefix; + } + const joinedPrefix = prefix.replace(/\/+$/, ''); + const joinedSuffix = suffix.replace(/^\/+/, ''); + return `${joinedPrefix}/${joinedSuffix}`; +} + +export class Request { + #body: string | Uint8Array | null; + #inner: RequestInner; + + constructor(url: URL | string, init: RequestInit = {}) { + this.#body = coerceRequestBody(init.body); + this.#inner = { + headers: new Headers(init.headers as any), + method: init.method ?? 'GET', + uri: '' + url, + version: init.version ?? { tag: 'Http11' }, + }; + } + + static [makeRequest](body: BodyInit | null, inner: RequestInner) { + const me = new Request(inner.uri); + me.#body = coerceRequestBody(body); + me.#inner = inner; + return me; + } + + get headers(): Headers { + return this.#inner.headers; + } + + get method(): string { + return this.#inner.method; + } + + get uri(): string { + return this.#inner.uri; + } + + get url(): string { + return this.#inner.uri; + } + + get version(): HttpVersion { + return this.#inner.version; + } + + arrayBuffer(): ArrayBuffer { + return this.bytes().buffer as ArrayBuffer; + } + + bytes(): Uint8Array { + return requestBodyToBytes(this.#body); + } + + json(): any { + return JSON.parse(this.text()); + } + + text(): string { + return requestBodyToText(this.#body); + } +} + +export interface HandlerContext { + readonly timestamp: Timestamp; + readonly http: HttpClient; + readonly identity: Identity; + readonly random: Random; + withTx(body: (ctx: TransactionCtx) => T): T; + newUuidV4(): Uuid; + newUuidV7(): Uuid; +} + +export type HandlerFn = ( + ctx: HandlerContext, + req: Request +) => SyncResponse; + +export interface HttpHandlerExport< + S extends UntypedSchemaDef = UntypedSchemaDef, +> extends ModuleExport { + [httpHandlerFn]: HandlerFn; +} + +const exportedHttpHandlerObjects = new WeakSet(); + +export interface HttpHandlerOpts { + name: string; +} + +export class Router { + #routes: RouteSpec[]; + + constructor(routes: RouteSpec[] = []) { + this.#routes = routes; + } + + get(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Get' } }, + path, + handler + ); + } + + head(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Head' } }, + path, + handler + ); + } + + options(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Options' } }, + path, + handler + ); + } + + put(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Put' } }, + path, + handler + ); + } + + delete(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Delete' } }, + path, + handler + ); + } + + post(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Post' } }, + path, + handler + ); + } + + patch(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Patch' } }, + path, + handler + ); + } + + any(path: string, handler: HttpHandlerExport) { + return this.addRoute({ tag: 'Any' }, path, handler); + } + + nest(path: string, subRouter: Router) { + assertValidPath(path); + if (this.#routes.some(route => route.path.startsWith(path))) { + throw new TypeError( + `Cannot nest router at \`${path}\`; existing routes overlap with nested path` + ); + } + let merged = new Router(this.#routes); + for (const route of subRouter.#routes) { + merged = merged.addRoute( + route.method, + joinPaths(path, route.path), + route.handler + ); + } + return merged; + } + + merge(otherRouter: Router) { + let merged = new Router(this.#routes); + for (const route of otherRouter.#routes) { + merged = merged.addRoute(route.method, route.path, route.handler); + } + return merged; + } + + intoRoutes() { + return this.#routes.slice(); + } + + private addRoute( + method: MethodOrAny, + path: string, + handler: HttpHandlerExport + ) { + assertValidPath(path); + const candidate = { method, path, handler }; + if (this.#routes.some(route => routesOverlap(route, candidate))) { + throw new TypeError(`Route conflict for \`${path}\``); + } + return new Router([...this.#routes, candidate]); + } +} + +export function makeHttpHandlerExport( + ctx: SchemaInner, + opts: HttpHandlerOpts | undefined, + fn: HandlerFn +): HttpHandlerExport { + const handlerExport = { + [httpHandlerFn]: fn, + [exportContext]: ctx, + [registerExport](ctx: SchemaInner, exportName: string) { + if (exportedHttpHandlerObjects.has(handlerExport)) { + throw new TypeError( + `HTTP handler '${exportName}' was exported more than once` + ); + } + exportedHttpHandlerObjects.add(handlerExport); + registerHttpHandler(ctx, exportName, fn, opts); + ctx.httpHandlerExports.set( + handlerExport as HttpHandlerExport, + exportName + ); + }, + }; + return handlerExport as HttpHandlerExport; +} + +export function makeHttpRouterExport( + ctx: SchemaInner, + router: Router +): ModuleExport { + return { + [exportContext]: ctx, + [registerExport](ctx: SchemaInner) { + ctx.pendingHttpRoutes.push(...router.intoRoutes()); + }, + }; +} + +function registerHttpHandler( + ctx: SchemaInner, + exportName: string, + fn: HandlerFn, + opts?: HttpHandlerOpts +) { + ctx.defineHttpHandler(exportName); + ctx.moduleDef.httpHandlers.push({ sourceName: exportName }); + + if (opts?.name != null) { + ctx.moduleDef.explicitNames.entries.push({ + tag: 'Function', + value: { + sourceName: exportName, + canonicalName: opts.name, + }, + }); + } + + if (!fn.name) { + Object.defineProperty(fn, 'name', { value: exportName, writable: false }); + } + + ctx.httpHandlers.push(fn as HandlerFn); +} diff --git a/crates/bindings-typescript/src/server/http_internal.ts b/crates/bindings-typescript/src/server/http_internal.ts index a58031e3a08..1ba1f31a5bf 100644 --- a/crates/bindings-typescript/src/server/http_internal.ts +++ b/crates/bindings-typescript/src/server/http_internal.ts @@ -1,133 +1,25 @@ -import { Headers, headersToList } from 'headers-polyfill'; -import status from 'statuses'; import BinaryReader from '../lib/binary_reader'; import BinaryWriter from '../lib/binary_writer'; -import { - HttpHeaders, - HttpMethod, - HttpRequest, - HttpResponse, -} from '../lib/http_types'; +import status from 'statuses'; +import { HttpRequest, HttpResponse } from '../lib/autogen/types'; import type { TimeDuration } from '../lib/time_duration'; import { bsatnBaseSize } from '../lib/util'; +import { + type BodyInit, + type HeadersInit, + deserializeHeaders, + Headers, + makeResponse, + serializeHeaders, + serializeMethod, + SyncResponse, +} from './http_shared'; import { sys } from './runtime'; export { Headers }; const { freeze } = Object; -export type BodyInit = ArrayBuffer | ArrayBufferView | string; -export type HeadersInit = [string, string][] | Record | Headers; -export interface ResponseInit { - headers?: HeadersInit; - status?: number; - statusText?: string; -} - -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder('utf-8' /* { fatal: true } */); - -function deserializeHeaders(headers: HttpHeaders): Headers { - return new Headers( - headers.entries.map(({ name, value }): [string, string] => [ - name, - textDecoder.decode(value), - ]) - ); -} - -const makeResponse = Symbol('makeResponse'); - -// based on deno's type of the same name -interface InnerResponse { - type: 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect'; - url: string | null; - status: number; - statusText: string; - headers: Headers; - aborted: boolean; -} - -export class SyncResponse { - #body: string | ArrayBuffer | null; - #inner: InnerResponse; - - constructor(body?: BodyInit | null, init?: ResponseInit) { - if (body == null) { - this.#body = null; - } else if (typeof body === 'string') { - this.#body = body; - } else { - // this call is fine, the typings are just weird - this.#body = new Uint8Array(body as any).buffer; - } - - // there's a type mismatch - headers-polyfill's typing doesn't expect its - // own `Headers` type, even though the actual code handles it correctly. - this.#inner = { - headers: new Headers(init?.headers as any), - status: init?.status ?? 200, - statusText: init?.statusText ?? '', - type: 'default', - url: null, - aborted: false, - }; - } - - static [makeResponse](body: BodyInit | null, inner: InnerResponse) { - const me = new SyncResponse(body); - me.#inner = inner; - return me; - } - - get headers(): Headers { - return this.#inner.headers; - } - get status(): number { - return this.#inner.status; - } - get statusText() { - return this.#inner.statusText; - } - get ok(): boolean { - return 200 <= this.#inner.status && this.#inner.status <= 299; - } - get url(): string { - return this.#inner.url ?? ''; - } - get type() { - return this.#inner.type; - } - - arrayBuffer(): ArrayBuffer { - return this.bytes().buffer; - } - - bytes(): Uint8Array { - if (this.#body == null) { - return new Uint8Array(); - } else if (typeof this.#body === 'string') { - return textEncoder.encode(this.#body); - } else { - return new Uint8Array(this.#body); - } - } - - json(): any { - return JSON.parse(this.text()); - } - - text(): string { - if (this.#body == null) { - return ''; - } else if (typeof this.#body === 'string') { - return this.#body; - } else { - return textDecoder.decode(this.#body); - } - } -} - export interface RequestOptions { /** A BodyInit object or null to set request's body. */ body?: BodyInit | null; @@ -147,29 +39,9 @@ export interface HttpClient { const requestBaseSize = bsatnBaseSize({ types: [] }, HttpRequest.algebraicType); -const methods = new Map([ - ['GET', { tag: 'Get' }], - ['HEAD', { tag: 'Head' }], - ['POST', { tag: 'Post' }], - ['PUT', { tag: 'Put' }], - ['DELETE', { tag: 'Delete' }], - ['CONNECT', { tag: 'Connect' }], - ['OPTIONS', { tag: 'Options' }], - ['TRACE', { tag: 'Trace' }], - ['PATCH', { tag: 'Patch' }], -]); - function fetch(url: URL | string, init: RequestOptions = {}) { - const method = methods.get(init.method?.toUpperCase() ?? 'GET') ?? { - tag: 'Extension', - value: init.method!, - }; - const headers: HttpHeaders = { - // anys because the typings are wonky - see comment in SyncResponse.constructor - entries: headersToList(new Headers(init.headers as any) as any) - .flatMap(([k, v]) => (Array.isArray(v) ? v.map(v => [k, v]) : [[k, v]])) - .map(([name, value]) => ({ name, value: textEncoder.encode(value) })), - }; + const method = serializeMethod(init.method); + const headers = serializeHeaders(new Headers(init.headers as any)); const uri = '' + url; const request: HttpRequest = freeze({ method, @@ -198,6 +70,7 @@ function fetch(url: URL | string, init: RequestOptions = {}) { statusText: status(response.code), headers: deserializeHeaders(response.headers), aborted: false, + version: response.version, }); } diff --git a/crates/bindings-typescript/src/server/http_shared.ts b/crates/bindings-typescript/src/server/http_shared.ts new file mode 100644 index 00000000000..ea422b7ecd1 --- /dev/null +++ b/crates/bindings-typescript/src/server/http_shared.ts @@ -0,0 +1,186 @@ +import { Headers, headersToList } from 'headers-polyfill'; +import type { + HttpHeaders, + HttpMethod, + HttpVersion, +} from '../lib/autogen/types'; + +export { Headers }; + +export type BodyInit = ArrayBuffer | ArrayBufferView | string; +export type HeadersInit = [string, string][] | Record | Headers; + +export const textEncoder = new TextEncoder(); +export const textDecoder = new TextDecoder('utf-8'); + +export function deserializeMethod(method: HttpMethod): string { + switch (method.tag) { + case 'Get': + return 'GET'; + case 'Head': + return 'HEAD'; + case 'Post': + return 'POST'; + case 'Put': + return 'PUT'; + case 'Delete': + return 'DELETE'; + case 'Connect': + return 'CONNECT'; + case 'Options': + return 'OPTIONS'; + case 'Trace': + return 'TRACE'; + case 'Patch': + return 'PATCH'; + case 'Extension': + return method.value; + } +} + +const methods = new Map([ + ['GET', { tag: 'Get' }], + ['HEAD', { tag: 'Head' }], + ['POST', { tag: 'Post' }], + ['PUT', { tag: 'Put' }], + ['DELETE', { tag: 'Delete' }], + ['CONNECT', { tag: 'Connect' }], + ['OPTIONS', { tag: 'Options' }], + ['TRACE', { tag: 'Trace' }], + ['PATCH', { tag: 'Patch' }], +]); + +export function serializeMethod(method?: string): HttpMethod { + return ( + methods.get(method?.toUpperCase() ?? 'GET') ?? { + tag: 'Extension', + value: method!, + } + ); +} + +export function serializeHeaders(headers: Headers): HttpHeaders { + return { + entries: headersToList(headers as any) + .flatMap(([k, v]) => (Array.isArray(v) ? v.map(v => [k, v]) : [[k, v]])) + .map(([name, value]) => ({ name, value: textEncoder.encode(value) })), + }; +} + +export function deserializeHeaders(headers: HttpHeaders): Headers { + return new Headers( + headers.entries.map(({ name, value }): [string, string] => [ + name, + textDecoder.decode(value), + ]) + ); +} + +export interface ResponseInit { + headers?: HeadersInit; + status?: number; + statusText?: string; + version?: HttpVersion; +} + +export interface InnerResponse { + type: 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect'; + url: string | null; + status: number; + statusText: string; + headers: Headers; + aborted: boolean; + version: HttpVersion; +} + +export const makeResponse = Symbol('makeResponse'); + +export class SyncResponse { + #body: string | ArrayBuffer | null; + #inner: InnerResponse; + + constructor(body?: BodyInit | null, init?: ResponseInit) { + if (body == null) { + this.#body = null; + } else if (typeof body === 'string') { + this.#body = body; + } else { + // this call is fine, the typings are just weird + this.#body = new Uint8Array(body as any).buffer; + } + + // there's a type mismatch - headers-polyfill's typing doesn't expect its + // own `Headers` type, even though the actual code handles it correctly. + this.#inner = { + headers: new Headers(init?.headers as any), + status: init?.status ?? 200, + statusText: init?.statusText ?? '', + type: 'default', + url: null, + aborted: false, + version: init?.version ?? { tag: 'Http11' }, + }; + } + + static [makeResponse](body: BodyInit | null, inner: InnerResponse) { + const me = new SyncResponse(body); + me.#inner = inner; + return me; + } + + get headers(): Headers { + return this.#inner.headers; + } + + get status(): number { + return this.#inner.status; + } + + get statusText() { + return this.#inner.statusText; + } + + get ok(): boolean { + return 200 <= this.#inner.status && this.#inner.status <= 299; + } + + get url(): string { + return this.#inner.url ?? ''; + } + + get type() { + return this.#inner.type; + } + + get version(): HttpVersion { + return this.#inner.version; + } + + arrayBuffer(): ArrayBuffer { + return this.bytes().buffer; + } + + bytes(): Uint8Array { + if (this.#body == null) { + return new Uint8Array(); + } else if (typeof this.#body === 'string') { + return textEncoder.encode(this.#body); + } else { + return new Uint8Array(this.#body); + } + } + + json(): any { + return JSON.parse(this.text()); + } + + text(): string { + if (this.#body == null) { + return ''; + } else if (typeof this.#body === 'string') { + return this.#body; + } else { + return textDecoder.decode(this.#body); + } + } +} diff --git a/crates/bindings-typescript/src/server/index.ts b/crates/bindings-typescript/src/server/index.ts index 7954ca407e8..a840be4a59d 100644 --- a/crates/bindings-typescript/src/server/index.ts +++ b/crates/bindings-typescript/src/server/index.ts @@ -22,5 +22,16 @@ export type { Uuid } from '../lib/uuid'; export type { Random } from './rng'; export type { ViewExport, ViewCtx, AnonymousViewCtx } from './views'; export { Range, type Bound } from './range'; +export { + Headers, + Request, + SyncResponse, + Router, + type BodyInit, + type HeadersInit, + type RequestInit, + type ResponseInit, +} from './http'; +export type { HandlerContext, HttpHandlerExport } from './http'; import './polyfills'; // Ensure polyfills are loaded diff --git a/crates/bindings-typescript/src/server/procedures.ts b/crates/bindings-typescript/src/server/procedures.ts index 39e5f58542f..d07b71f5185 100644 --- a/crates/bindings-typescript/src/server/procedures.ts +++ b/crates/bindings-typescript/src/server/procedures.ts @@ -22,7 +22,7 @@ import { Uuid } from '../lib/uuid'; import { httpClient, type HttpClient } from './http_internal'; import type { DbView } from './db_view'; import { makeRandom, type Random } from './rng'; -import { callUserFunction, ReducerCtxImpl, sys } from './runtime'; +import { callUserFunction, ReducerCtxImpl, runWithTx, sys } from './runtime'; import { exportContext, registerExport, @@ -214,38 +214,16 @@ const ProcedureCtxImpl = class ProcedureCtx } withTx(body: (ctx: TransactionCtx) => T): T { - const run = () => { - const timestamp = sys.procedure_start_mut_tx(); - - try { - const ctx: TransactionCtx = new TransactionCtxImpl( + return runWithTx( + timestamp => + new TransactionCtxImpl( this.sender, - new Timestamp(timestamp), + timestamp, this.connectionId, this.#dbView() - ); - return body(ctx); - } catch (e) { - sys.procedure_abort_mut_tx(); - throw e; - } - }; - - let res = run(); - try { - sys.procedure_commit_mut_tx(); - return res; - } catch { - // ignore the commit error - } - console.warn('committing anonymous transaction failed'); - res = run(); - try { - sys.procedure_commit_mut_tx(); - return res; - } catch (e) { - throw new Error('transaction retry failed again', { cause: e }); - } + ) as TransactionCtx, + body + ); } newUuidV4(): Uuid { diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index 5031b1d850c..b3121dc2085 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -27,6 +27,18 @@ import { type UniqueIndex, } from '../lib/indexes'; import { callProcedure } from './procedures'; +import { + type HandlerContext, + Request, + SyncResponse, + makeRequest, +} from './http_handlers'; +import { httpClient } from './http_internal'; +import { + deserializeHeaders, + deserializeMethod, + serializeHeaders, +} from './http_shared'; import { type AuthCtx, type JsonObject, @@ -35,7 +47,7 @@ import { } from '../lib/reducers'; import { type UntypedSchemaDef } from '../lib/schema'; import { type RowType, type Table, type TableMethods } from '../lib/table'; -import { hasOwn } from '../lib/util'; +import { bsatnBaseSize, hasOwn } from '../lib/util'; import { type AnonymousViewCtx, type ViewCtx } from './views'; import { isRowTypedQuery, makeQueryBuilder, toSql } from './query'; import type { DbView } from './db_view'; @@ -43,11 +55,32 @@ import { getErrorConstructor, SenderError } from './errors'; import { Range, type Bound } from './range'; import { makeRandom, type Random } from './rng'; import type { SchemaInner } from './schema'; +import { HttpRequest, HttpResponse } from '../lib/autogen/types'; const { freeze } = Object; export const sys = { ..._syscalls2_0, ..._syscalls2_1 }; +function requestFromWire(request: HttpRequest, body: Uint8Array): Request { + return Request[makeRequest](body, { + headers: deserializeHeaders(request.headers), + method: deserializeMethod(request.method), + uri: request.uri, + version: request.version, + }); +} + +function responseIntoWire(response: SyncResponse): [HttpResponse, Uint8Array] { + return [ + { + headers: serializeHeaders(response.headers), + version: response.version, + code: response.status, + }, + response.bytes(), + ]; +} + export function parseJsonObject(json: string): JsonObject { let value: unknown; @@ -272,6 +305,38 @@ export const callUserFunction = function __spacetimedb_end_short_backtrace< return fn(...args); }; +export function runWithTx( + makeCtx: (timestamp: Timestamp) => Ctx, + body: (ctx: Ctx) => T +): T { + const run = () => { + const timestamp = sys.procedure_start_mut_tx(); + + try { + return body(makeCtx(new Timestamp(timestamp))); + } catch (e) { + sys.procedure_abort_mut_tx(); + throw e; + } + }; + + let res = run(); + try { + sys.procedure_commit_mut_tx(); + return res; + } catch { + // ignore the commit error + } + console.warn('committing anonymous transaction failed'); + res = run(); + try { + sys.procedure_commit_mut_tx(); + return res; + } catch (e) { + throw new Error('transaction retry failed again', { cause: e }); + } +} + export const makeHooks = (schema: SchemaInner): ModuleHooks => new ModuleHooksImpl(schema); @@ -418,11 +483,82 @@ class ModuleHooksImpl implements ModuleHooks { () => this.#dbView ); } + + __call_http_handler__( + id: u32, + timestamp: bigint, + request: Uint8Array, + body: Uint8Array + ): [response: Uint8Array, body: Uint8Array] { + const moduleCtx = this.#schema; + const handler = moduleCtx.httpHandlers[id]; + const ctx = new HandlerContextImpl( + new Timestamp(timestamp), + () => this.#dbView + ); + const requestMetadata = HttpRequest.deserialize(new BinaryReader(request)); + const response = callUserFunction( + handler, + ctx, + requestFromWire(requestMetadata, body) + ); + const [responseMetadata, responseBody] = responseIntoWire(response); + const responseBuf = new BinaryWriter( + bsatnBaseSize(moduleCtx.typespace, HttpResponse.algebraicType) + ); + HttpResponse.serialize(responseBuf, responseMetadata); + return [responseBuf.getBuffer(), responseBody]; + } } const BINARY_WRITER = new BinaryWriter(0); const BINARY_READER = new BinaryReader(new Uint8Array()); +class HandlerContextImpl + implements HandlerContext +{ + #identity: Identity | undefined; + #uuidCounter: { value: number } | undefined; + #random: Random | undefined; + #dbView: () => DbView; + + readonly http = httpClient; + + constructor( + readonly timestamp: Timestamp, + dbView: () => DbView + ) { + this.#dbView = dbView; + } + + get identity() { + return (this.#identity ??= new Identity(sys.identity())); + } + + get random() { + return (this.#random ??= makeRandom(this.timestamp)); + } + + withTx(body: (ctx: any) => T): T { + return runWithTx( + timestamp => + new ReducerCtxImpl(Identity.zero(), timestamp, null, this.#dbView()), + body + ); + } + + newUuidV4(): Uuid { + const bytes = this.random.fill(new Uint8Array(16)); + return Uuid.fromRandomBytesV4(bytes); + } + + newUuidV7(): Uuid { + const bytes = this.random.fill(new Uint8Array(4)); + const counter = (this.#uuidCounter ??= { value: 0 }); + return Uuid.fromCounterV7(counter, this.timestamp, bytes); + } +} + function makeTableView( typespace: Typespace, table: RawTableDefV10 diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts index b9eb258762b..d9f20be3025 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -1,5 +1,9 @@ import { moduleHooks, type ModuleDefaultExport } from 'spacetime:sys@2.0'; -import { CaseConversionPolicy, Lifecycle } from '../lib/autogen/types'; +import { + CaseConversionPolicy, + Lifecycle, + type MethodOrAny, +} from '../lib/autogen/types'; import { type ParamsAsObject, type ParamsObj, @@ -14,6 +18,14 @@ import { } from '../lib/schema'; import type { UntypedTableSchema } from '../lib/table_schema'; import { ColumnBuilder, TypeBuilder } from '../lib/type_builders'; +import { + Router, + type HandlerFn, + type HttpHandlerExport, + type HttpHandlerOpts, + makeHttpHandlerExport, + makeHttpRouterExport, +} from './http_handlers'; import { makeProcedureExport, type ProcedureExport, @@ -47,10 +59,12 @@ export class SchemaInner< > extends ModuleContext { schemaType: S; existingFunctions = new Set(); + existingHttpHandlers = new Set(); reducers: Reducers = []; procedures: Procedures = []; views: Views = []; anonViews: AnonViews = []; + httpHandlers: HandlerFn[] = []; /** * Maps ReducerExport objects to the name of the reducer. * Used for resolving the reducers of scheduled tables. @@ -60,7 +74,10 @@ export class SchemaInner< | ProcedureExport, string > = new Map(); + httpHandlerExports: Map, string> = + new Map(); pendingSchedules: PendingSchedule[] = []; + pendingHttpRoutes: PendingHttpRoute[] = []; constructor(getSchemaType: (ctx: SchemaInner) => S) { super(); @@ -70,12 +87,21 @@ export class SchemaInner< defineFunction(name: string) { if (this.existingFunctions.has(name)) { throw new TypeError( - `There is already a reducer or procedure with the name '${name}'` + `There is already a reducer, procedure, or view with the name '${name}'` ); } this.existingFunctions.add(name); } + defineHttpHandler(name: string) { + if (this.existingHttpHandlers.has(name)) { + throw new TypeError( + `There is already an HTTP handler with the name '${name}'` + ); + } + this.existingHttpHandlers.add(name); + } + resolveSchedules() { for (const { reducer, scheduleAtCol, tableName } of this.pendingSchedules) { const functionName = this.functionExports.get(reducer()); @@ -91,9 +117,30 @@ export class SchemaInner< }); } } + + resolveHttpRoutes() { + for (const route of this.pendingHttpRoutes) { + const handlerFunction = this.httpHandlerExports.get(route.handler); + if (handlerFunction === undefined) { + throw new TypeError( + `HTTP route for path '${route.path}' refers to a handler that was not exported.` + ); + } + this.moduleDef.httpRoutes.push({ + handlerFunction, + method: route.method, + path: route.path, + }); + } + } } type PendingSchedule = UntypedTableSchema['schedule'] & { tableName: string }; +type PendingHttpRoute = { + handler: HttpHandlerExport; + method: MethodOrAny; + path: string; +}; /** * The Schema class represents the database schema for a SpacetimeDB application. @@ -153,6 +200,7 @@ export class Schema implements ModuleDefaultExport { moduleExport[registerExport](registeredSchema, name); } registeredSchema.resolveSchedules(); + registeredSchema.resolveHttpRoutes(); return makeHooks(registeredSchema); } @@ -458,6 +506,27 @@ export class Schema implements ModuleDefaultExport { return makeProcedureExport(this.#ctx, opts, params, ret, fn); } + httpHandler(fn: HandlerFn): HttpHandlerExport; + httpHandler(opts: HttpHandlerOpts, fn: HandlerFn): HttpHandlerExport; + httpHandler( + ...args: [HandlerFn] | [HttpHandlerOpts, HandlerFn] + ): HttpHandlerExport { + let opts: HttpHandlerOpts | undefined, fn: HandlerFn; + switch (args.length) { + case 1: + [fn] = args; + break; + case 2: + [opts, fn] = args; + break; + } + return makeHttpHandlerExport(this.#ctx, opts, fn); + } + + httpRouter(router: Router): ModuleExport { + return makeHttpRouterExport(this.#ctx, router); + } + /** * Bundle multiple reducers, procedures, etc into one value to export. * The name they will be exported with is their corresponding key in the `exports` argument. diff --git a/crates/bindings-typescript/src/server/sys.d.ts b/crates/bindings-typescript/src/server/sys.d.ts index 71599c8f92a..f0315867cb3 100644 --- a/crates/bindings-typescript/src/server/sys.d.ts +++ b/crates/bindings-typescript/src/server/sys.d.ts @@ -37,6 +37,13 @@ declare module 'spacetime:sys@2.0' { timestamp: bigint, args: Uint8Array ): Uint8Array; + + __call_http_handler__( + id: u32, + timestamp: bigint, + request: Uint8Array, + body: Uint8Array + ): [response: Uint8Array, body: Uint8Array]; } export function register_hooks(hooks: ModuleHooks); diff --git a/crates/bindings-typescript/src/server/views.ts b/crates/bindings-typescript/src/server/views.ts index accd0c92563..6d2b475b177 100644 --- a/crates/bindings-typescript/src/server/views.ts +++ b/crates/bindings-typescript/src/server/views.ts @@ -143,6 +143,7 @@ export function registerView< ? AnonymousViewFn : ViewFn ) { + ctx.defineFunction(exportName); const paramsBuilder = new RowBuilder(params, toPascalCase(exportName)); // Register return types if they are product types diff --git a/crates/bindings-typescript/tests/http_handlers.test.ts b/crates/bindings-typescript/tests/http_handlers.test.ts new file mode 100644 index 00000000000..fef87c4d922 --- /dev/null +++ b/crates/bindings-typescript/tests/http_handlers.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it, vi } from 'vitest'; + +const registerExport = Symbol('SpacetimeDB.registerExport'); +const exportContext = Symbol('SpacetimeDB.exportContext'); + +vi.mock('../src/server/schema', () => ({ + exportContext, + registerExport, +})); + +vi.mock('../src/server/http_internal', () => ({ + httpClient: {}, +})); + +describe('http request/response api', async () => { + const { Request, SyncResponse } = await import('../src/server/http_handlers'); + + it('preserves the provided request method string', () => { + const request = new Request('https://example.test/items', { + method: 'MyMethod', + }); + + expect(request.method).toBe('MyMethod'); + }); + + it('reads request text, json, and bytes', () => { + const request = new Request('https://example.test/items', { + method: 'POST', + body: JSON.stringify({ ok: true }), + }); + + expect(request.text()).toBe('{"ok":true}'); + expect(request.json()).toEqual({ ok: true }); + expect(Array.from(request.bytes())).toEqual( + Array.from(new TextEncoder().encode('{"ok":true}')) + ); + }); + + it('defaults response status text to empty string', () => { + const response = new SyncResponse('created', { status: 201 }); + + expect(response.status).toBe(201); + expect(response.statusText).toBe(''); + expect(response.ok).toBe(true); + }); + + it('marks non-2xx responses as not ok', () => { + const response = new SyncResponse('teapot', { status: 418 }); + + expect(response.ok).toBe(false); + expect(response.text()).toBe('teapot'); + }); + + it('supports array buffer bodies', () => { + const response = new SyncResponse(new TextEncoder().encode('bytes')); + + expect(response.text()).toBe('bytes'); + expect(Array.from(response.bytes())).toEqual( + Array.from(new TextEncoder().encode('bytes')) + ); + }); +}); + +describe('http handler exports', async () => { + const { SyncResponse } = await import('../src/server/http_handlers'); + const { makeHttpHandlerExport } = await import('../src/server/http_handlers'); + + function makeCtx() { + return { + moduleDef: { + httpHandlers: [] as Array<{ sourceName: string }>, + explicitNames: { entries: [] as unknown[] }, + }, + existingHttpHandlers: new Set(), + httpHandlers: [] as Array, + httpHandlerExports: new Map(), + defineHttpHandler(name: string) { + if (this.existingHttpHandlers.has(name)) { + throw new TypeError( + `There is already an HTTP handler with the name '${name}'` + ); + } + this.existingHttpHandlers.add(name); + }, + }; + } + + it('rejects exporting the same handler object more than once', () => { + const ctx = makeCtx(); + const handler = makeHttpHandlerExport(ctx as never, undefined, () => { + return new SyncResponse('ok'); + }); + + handler[registerExport](ctx as never, 'hello'); + + expect(() => handler[registerExport](ctx as never, 'helloAgain')).toThrow( + "HTTP handler 'helloAgain' was exported more than once" + ); + }); + + it('allows distinct handler export objects for distinct handlers', () => { + const ctx = makeCtx(); + const first = makeHttpHandlerExport(ctx as never, undefined, () => { + return new SyncResponse('first'); + }); + const second = makeHttpHandlerExport(ctx as never, undefined, () => { + return new SyncResponse('second'); + }); + + expect(() => { + first[registerExport](ctx as never, 'first'); + second[registerExport](ctx as never, 'second'); + }).not.toThrow(); + }); + + it('rejects duplicate exported handler names', () => { + const ctx = makeCtx(); + const first = makeHttpHandlerExport(ctx as never, undefined, () => { + return new SyncResponse('first'); + }); + const second = makeHttpHandlerExport(ctx as never, undefined, () => { + return new SyncResponse('second'); + }); + + first[registerExport](ctx as never, 'hello'); + + expect(() => second[registerExport](ctx as never, 'hello')).toThrow( + "There is already an HTTP handler with the name 'hello'" + ); + }); + + it('records the originating schema context on the export', () => { + const ctx = makeCtx(); + const handler = makeHttpHandlerExport(ctx as never, undefined, () => { + return new SyncResponse('ok'); + }); + + expect((handler as Record)[exportContext]).toBe(ctx); + }); +}); + +describe('http router', async () => { + const { Router } = await import('../src/server/http_handlers'); + type HttpHandlerExport = + import('../src/server/http_handlers').HttpHandlerExport; + + function handler(): HttpHandlerExport { + return {} as HttpHandlerExport; + } + + it('accepts strict root and slash root routes as distinct', () => { + expect(() => + new Router().get('', handler()).get('/', handler()).get('/foo', handler()) + ).not.toThrow(); + }); + + it('rejects paths without a leading slash unless they are empty root', () => { + expect(() => new Router().get('foo', handler())).toThrow( + 'Route paths must start with `/`: foo' + ); + }); + + it('rejects invalid path characters', () => { + expect(() => new Router().get('/Hello', handler())).toThrow( + 'Route paths may contain only ASCII lowercase letters, digits and `-_~/`: /Hello' + ); + }); + + it('allows distinct methods on the same path', () => { + expect(() => + new Router().get('/echo', handler()).post('/echo', handler()) + ).not.toThrow(); + }); + + it('rejects duplicate same-method same-path routes', () => { + expect(() => + new Router().get('/echo', handler()).get('/echo', handler()) + ).toThrow('Route conflict for `/echo`'); + }); + + it('rejects any() routes that overlap a method-specific route', () => { + expect(() => + new Router().get('/echo', handler()).any('/echo', handler()) + ).toThrow('Route conflict for `/echo`'); + }); + + it('treats trailing slash variants as distinct non-root routes', () => { + expect(() => + new Router().get('/foo', handler()).get('/foo/', handler()) + ).not.toThrow(); + }); + + it('nests paths by joining prefixes and suffixes', () => { + const nested = new Router() + .nest('/api', new Router().get('/users', handler()).get('/', handler())) + .intoRoutes(); + + expect(nested).toHaveLength(2); + expect(nested.map(route => route.path)).toEqual(['/api/users', '/api']); + }); + + it('rejects nesting when an existing route overlaps the nested prefix', () => { + expect(() => + new Router().get('/api/users', handler()).nest('/api', new Router()) + ).toThrow( + 'Cannot nest router at `/api`; existing routes overlap with nested path' + ); + }); + + it('treats sibling prefixes as overlapping nested paths', () => { + expect(() => + new Router().get('/foobar', handler()).nest('/foo', new Router()) + ).toThrow( + 'Cannot nest router at `/foo`; existing routes overlap with nested path' + ); + }); + + it('preserves Rust trailing-slash behavior for nested empty paths', () => { + const nested = new Router().nest( + '/prefix', + new Router().get('', handler()) + ); + + expect(nested.intoRoutes().map(route => route.path)).toEqual(['/prefix/']); + }); + + it('rejects merge() conflicts', () => { + expect(() => + new Router() + .get('/echo', handler()) + .merge(new Router().get('/echo', handler())) + ).toThrow('Route conflict for `/echo`'); + }); +}); diff --git a/crates/bindings-typescript/tests/http_headers.test.ts b/crates/bindings-typescript/tests/http_headers.test.ts index 0cba0d90b2e..8024ca18fc9 100644 --- a/crates/bindings-typescript/tests/http_headers.test.ts +++ b/crates/bindings-typescript/tests/http_headers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; import { BinaryReader, BinaryWriter } from '../src'; -import { HttpResponse } from '../src/lib/http_types'; +import { HttpResponse } from '../src/lib/autogen/types'; describe('HttpResponse header round-trip', () => { test('headers survive BSATN serialize/deserialize', () => { diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index cf9e4b343c6..60576cf752b 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -299,14 +299,6 @@ async fn handle_http_route_impl( Err(spacetimedb::host::module_host::HttpHandlerCallError::NoSuchHandler) => { return Ok((StatusCode::NOT_FOUND, NO_SUCH_ROUTE).into_response()); } - // TODO(v8-http-handlers): Remove. - Err(spacetimedb::host::module_host::HttpHandlerCallError::UnsupportedHostType) => { - return Err(( - StatusCode::NOT_IMPLEMENTED, - "HTTP handlers are not supported for this module", - ) - .into()); - } Err(spacetimedb::host::module_host::HttpHandlerCallError::NoSuchModule(_)) => { return Err(NO_SUCH_DATABASE.into()); } diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index e2c07a6cc58..04130f17998 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -1529,11 +1529,6 @@ pub enum HttpHandlerCallError { NoSuchModule(#[from] NoSuchModule), #[error("no such http handler")] NoSuchHandler, - - // TODO(v8-http-handlers): Remove this error variant. - #[error("http handlers are not supported for this host type")] - UnsupportedHostType, - #[error("The module instance encountered a fatal error: {0}")] InternalError(String), } @@ -2766,8 +2761,7 @@ impl ModuleHost { "http handler", params, |params, inst| inst.call_http_handler(params).await, - // TODO(v8-http-handlers): Do something useful here. - |_params, _inst| Err(HttpHandlerCallError::UnsupportedHostType), + |params, inst| inst.call_http_handler(params).await, )? } diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index 9833af27c0e..56424b440f1 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -61,12 +61,13 @@ use self::error::{ use self::ser::serialize_to_js; use self::string::{str_from_ident, IntoJsString}; use self::syscall::{ - call_call_procedure, call_call_reducer, call_call_view, call_call_view_anon, call_describe_module, get_hooks, - process_thrown_exception, resolve_sys_module, FnRet, HookFunctions, + call_call_http_handler, call_call_procedure, call_call_reducer, call_call_view, call_call_view_anon, + call_describe_module, get_hooks, process_thrown_exception, resolve_sys_module, FnRet, HookFunctions, }; use super::module_common::{build_common_module_from_raw, run_describer, ModuleCommon}; use super::module_host::{ - CallProcedureParams, CallReducerParams, InstanceManagerMetrics, ModuleInfo, ModuleWithInstance, + CallHttpHandlerParams, CallProcedureParams, CallReducerParams, InstanceManagerMetrics, ModuleInfo, + ModuleWithInstance, }; use super::UpdateDatabaseResult; use crate::client::{ClientActorId, MeteredUnboundedReceiver, MeteredUnboundedSender}; @@ -74,13 +75,13 @@ use crate::config::{V8Config, V8HeapPolicyConfig}; use crate::host::host_controller::CallProcedureReturn; use crate::host::instance_env::{ChunkPool, InstanceEnv, TxSlot}; use crate::host::module_host::{ - call_identity_connected, init_database, ClientConnectedError, OneOffQueryRequest, SqlCommand, SqlCommandResult, - ViewCommand, ViewCommandMetric, ViewCommandResult, + call_identity_connected, init_database, ClientConnectedError, HttpHandlerCallError, OneOffQueryRequest, SqlCommand, + SqlCommandResult, ViewCommand, ViewCommandMetric, ViewCommandResult, }; use crate::host::scheduler::{CallScheduledFunctionResult, ScheduledFunctionParams}; use crate::host::wasm_common::instrumentation::CallTimes; use crate::host::wasm_common::module_host_actor::{ - AnonymousViewOp, DescribeError, EnergyStats, ExecutionError, ExecutionResult, ExecutionStats, ExecutionTimings, + AnonymousViewOp, DescribeError, ExecutionError, ExecutionResult, ExecutionStats, ExecutionTimings, HttpHandlerExecuteResult, HttpHandlerOp, InstanceCommon, InstanceOp, ProcedureExecuteResult, ProcedureOp, ReducerExecuteResult, ReducerOp, ViewExecuteResult, ViewOp, WasmInstance, }; @@ -702,6 +703,16 @@ impl JsProcedureInstance { .await } + pub async fn call_http_handler( + &self, + params: CallHttpHandlerParams, + ) -> Result<(spacetimedb_lib::http::Response, bytes::Bytes), HttpHandlerCallError> { + self.send_request("call_http_handler", |reply_tx| { + JsProcedureWorkerRequest::CallHttpHandler { reply_tx, params } + }) + .await + } + pub(in crate::host) async fn enqueue_procedure(&self, params: CallProcedureParams) -> JsProcedureCall { let (reply_tx, reply_rx) = oneshot::channel(); if self @@ -835,7 +846,7 @@ enum JsMainWorkerRequest { request: OneOffQueryRequest, on_panic: JsFatalHook, }, - /// See [`JsMainInstance::clear_all_clients`]. + /// See [`JsInstance::clear_all_clients`]. ClearAllClients(JsReplyTx>), /// See [`JsMainInstance::call_identity_connected`]. CallIdentityConnected { @@ -873,6 +884,11 @@ enum JsProcedureWorkerRequest { reply_tx: JsReplyTx, params: ScheduledFunctionParams, }, + /// See [`JsInstance::call_http_handler`]. + CallHttpHandler { + reply_tx: JsReplyTx>, + params: CallHttpHandlerParams, + }, } static_assert_size!(CallReducerParams, 192); @@ -1460,6 +1476,15 @@ fn handle_procedure_worker_request( (res, trapped) }) } + JsProcedureWorkerRequest::CallHttpHandler { reply_tx, params } => { + handle_worker_request("call_http_handler", reply_tx, || { + let (res, trapped) = instance_common + .call_http_handler(params, inst) + .now_or_never() + .expect("our call_http_handler implementation is not actually async"); + (res, trapped) + }) + } JsProcedureWorkerRequest::ScheduledProcedure { reply_tx, params } => { handle_worker_request("scheduled_procedure", reply_tx, || { let (res, trapped) = instance_common @@ -1852,17 +1877,18 @@ impl WasmInstance for V8Instance<'_, '_, '_> { async fn call_http_handler( &mut self, - _op: HttpHandlerOp, - _budget: FunctionBudget, + op: HttpHandlerOp, + budget: FunctionBudget, ) -> (HttpHandlerExecuteResult, Option) { - let result = ExecutionResult { - stats: ExecutionStats { - energy: EnergyStats::ZERO, - timings: ExecutionTimings::zero(), - memory_allocation: 0, - }, - call_result: Err(anyhow::anyhow!("HTTP handlers are not supported for JS modules")), - }; + let result = common_call(self, budget, op, |scope, hooks, op| { + call_call_http_handler(scope, hooks, op) + }) + .map_result(|call_result| { + call_result.map_err(|e| match e { + ExecutionError::User(e) => anyhow::Error::msg(e), + ExecutionError::Recoverable(e) | ExecutionError::Trap(e) => e, + }) + }); (result, None) } } diff --git a/crates/core/src/host/v8/syscall/common.rs b/crates/core/src/host/v8/syscall/common.rs index 5e96ef9a1ab..3d93e0b2679 100644 --- a/crates/core/src/host/v8/syscall/common.rs +++ b/crates/core/src/host/v8/syscall/common.rs @@ -17,7 +17,8 @@ use crate::database_logger::{LogLevel, Record}; use crate::error::NodesError; use crate::host::instance_env::InstanceEnv; use crate::host::wasm_common::module_host_actor::{ - deserialize_view_rows, run_query_for_view, AnonymousViewOp, ProcedureOp, ViewOp, ViewResult, ViewReturnData, + deserialize_view_rows, run_query_for_view, AnonymousViewOp, HttpHandlerOp, ProcedureOp, ViewOp, ViewResult, + ViewReturnData, }; use crate::host::wasm_common::{RowIterIdx, TimingSpan, TimingSpanIdx}; use anyhow::Context; @@ -65,6 +66,63 @@ pub fn call_call_procedure( Ok(Bytes::copy_from_slice(bytes)) } +/// Calls the `__call_http_handler__` function `fun`. +pub fn call_call_http_handler( + scope: &mut PinScope<'_, '_>, + hooks: &HookFunctions<'_>, + op: HttpHandlerOp, +) -> Result<(Bytes, Bytes), ErrorOrException> { + let fun = hooks + .call_http_handler + .context("`__call_http_handler__` was never defined")?; + + let HttpHandlerOp { + id, + name: _, + timestamp, + request_bytes, + request_body_bytes, + } = op; + + let handler_id = serialize_to_js(scope, &id.0)?; + let timestamp = serialize_to_js(scope, ×tamp.to_micros_since_unix_epoch())?; + let request = serialize_to_js(scope, &request_bytes)?; + let request_body = serialize_to_js(scope, &request_body_bytes)?; + let args = &[handler_id, timestamp, request, request_body]; + + let ret = call_recv_fun(scope, fun, hooks.recv, args)?; + let ret = cast!(scope, ret, v8::Array, "tuple return from `__call_http_handler__`").map_err(|e| e.throw(scope))?; + + if ret.length() != 2 { + return Err(TypeError("`__call_http_handler__` must return a two-element array") + .throw(scope) + .into()); + } + + let response = ret.get_index(scope, 0).ok_or_else(exception_already_thrown)?; + let response = cast!( + scope, + response, + v8::Uint8Array, + "response bytes return from `__call_http_handler__`" + ) + .map_err(|e| e.throw(scope))?; + + let body = ret.get_index(scope, 1).ok_or_else(exception_already_thrown)?; + let body = cast!( + scope, + body, + v8::Uint8Array, + "response body bytes return from `__call_http_handler__`" + ) + .map_err(|e| e.throw(scope))?; + + Ok(( + Bytes::copy_from_slice(response.get_contents(&mut [])), + Bytes::copy_from_slice(body.get_contents(&mut [])), + )) +} + /// Calls the registered `__describe_module__` function hook. pub fn call_describe_module( scope: &mut PinScope<'_, '_>, diff --git a/crates/core/src/host/v8/syscall/hooks.rs b/crates/core/src/host/v8/syscall/hooks.rs index 8043061fb98..66c13b1ceb1 100644 --- a/crates/core/src/host/v8/syscall/hooks.rs +++ b/crates/core/src/host/v8/syscall/hooks.rs @@ -58,6 +58,9 @@ pub(in super::super) fn set_registered_hooks(scope: &mut PinScope<'_, '_>, hooks if let Some(call_procedure) = hooks.call_procedure { to_register.push((ModuleHookKey::CallProcedure, call_procedure)); } + if let Some(call_http_handler) = hooks.call_http_handler { + to_register.push((ModuleHookKey::CallHttpHandler, call_http_handler)); + } if let Some(get_error_constructor) = hooks.get_error_constructor { to_register.push((ModuleHookKey::GetErrorConstructor, get_error_constructor)); } @@ -80,6 +83,7 @@ pub(in super::super) enum ModuleHookKey { CallView, CallAnonymousView, CallProcedure, + CallHttpHandler, GetErrorConstructor, SenderErrorClass, } @@ -143,6 +147,7 @@ pub(in super::super) struct HookFunctions<'scope> { pub call_view: Option>, pub call_view_anon: Option>, pub call_procedure: Option>, + pub call_http_handler: Option>, } /// Returns the hook function previously registered in [`register_hooks`]. @@ -172,5 +177,6 @@ pub(in super::super) fn get_registered_hooks<'scope>( call_view: get(ModuleHookKey::CallView), call_view_anon: get(ModuleHookKey::CallAnonymousView), call_procedure: get(ModuleHookKey::CallProcedure), + call_http_handler: get(ModuleHookKey::CallHttpHandler), }) } diff --git a/crates/core/src/host/v8/syscall/mod.rs b/crates/core/src/host/v8/syscall/mod.rs index 4ad8b0ec1ba..a09e7cbba0c 100644 --- a/crates/core/src/host/v8/syscall/mod.rs +++ b/crates/core/src/host/v8/syscall/mod.rs @@ -115,7 +115,7 @@ pub(super) fn call_call_view_anon( } } -pub use self::common::{call_call_procedure, call_describe_module}; +pub use self::common::{call_call_http_handler, call_call_procedure, call_describe_module}; /// Get the hooks for the module. /// diff --git a/crates/core/src/host/v8/syscall/v2.rs b/crates/core/src/host/v8/syscall/v2.rs index fd8645f00a2..f49d2260549 100644 --- a/crates/core/src/host/v8/syscall/v2.rs +++ b/crates/core/src/host/v8/syscall/v2.rs @@ -402,6 +402,19 @@ pub fn get_hooks_from_default_export<'scope>( let call_view = get_hook_function(scope, hooks, str_from_ident!(__call_view__))?; let call_view_anon = get_hook_function(scope, hooks, str_from_ident!(__call_view_anon__))?; let call_procedure = get_hook_function(scope, hooks, str_from_ident!(__call_procedure__))?; + // `call_http_handler` is optional, unlike the other hooks. + // This is because HTTP handler support was added after the initial release of TypeScript modules, + // and so we need to continue supporting precompiled TypeScript and JS modules + // which used an earlier version of the bindings package, + // prior to the inclusion of `__call_http_handler__`. + let call_http_handler = { + let key = str_from_ident!(__call_http_handler__).string(scope); + let value = hooks.get(scope, key.into()).ok_or_else(exception_already_thrown)?; + (!value.is_null_or_undefined()) + .then(|| cast!(scope, value, Function, "module function hook `__call_http_handler__`")) + .transpose() + .map_err(|e| e.throw(scope))? + }; // Cache hooks in context slots so syscall-time code can reconstruct them. let hooks = HookFunctions { @@ -414,6 +427,7 @@ pub fn get_hooks_from_default_export<'scope>( call_view: Some(call_view), call_view_anon: Some(call_view_anon), call_procedure: Some(call_procedure), + call_http_handler, }; set_registered_hooks(scope, &hooks)?; Ok(Some(hooks)) diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index aab3e0bcbe3..538e7054518 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -1,5 +1,5 @@ use regex::Regex; -use spacetimedb_smoketests::{require_dotnet, workspace_root, Smoketest}; +use spacetimedb_smoketests::{require_dotnet, require_pnpm, workspace_root, Smoketest}; use std::{fs, path::Path}; const MODULE_CODE: &str = r#" @@ -230,6 +230,198 @@ fn router() -> Router { } "#; +const TS_MODULE_CODE: &str = r#"import { Router, SyncResponse, schema, table, t } from "spacetimedb/server"; + +const entry = table( + { name: "entry", public: true }, + { + id: t.u64().primaryKey(), + value: t.string(), + } +); + +const spacetimedb = schema({ entry }); +export default spacetimedb; + +export const get_simple = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("ok") +); + +export const post_insert = spacetimedb.httpHandler((ctx, _req) => { + ctx.withTx(tx => { + const id = BigInt(tx.db.entry.count()); + tx.db.entry.insert({ id, value: "posted" }); + }); + return new SyncResponse("inserted"); +}); + +export const get_count = spacetimedb.httpHandler((ctx, _req) => { + const count = ctx.withTx(tx => tx.db.entry.count()); + return new SyncResponse(String(count)); +}); + +export const any_handler = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("any") +); + +export const header_echo = spacetimedb.httpHandler((_ctx, req) => + new SyncResponse(req.headers.get("x-echo") ?? "") +); + +export const set_response_header = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("header-set", { headers: { "x-response": "set" } }) +); + +export const body_handler = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("non-empty") +); + +export const teapot = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("teapot", { status: 418 }) +); + +export const router = spacetimedb.httpRouter( + new Router() + .get("/get", get_simple) + .post("/post", post_insert) + .get("/count", get_count) + .any("/any", any_handler) + .get("/header", header_echo) + .get("/set-header", set_response_header) + .get("/body", body_handler) + .get("/teapot", teapot) +); +"#; + +const TS_EXAMPLE_MODULE_CODE: &str = r#"import { Router, SyncResponse, schema, table, t } from "spacetimedb/server"; + +const data = table( + { name: "data" }, + { + id: t.u64().primaryKey().autoInc(), + body: t.array(t.u8()), + } +); + +const spacetimedb = schema({ data }); +export default spacetimedb; + +export const insert = spacetimedb.httpHandler((ctx, req) => { + const body = Array.from(req.bytes()); + const id = ctx.withTx(tx => tx.db.data.insert({ id: 0n, body }).id); + return new SyncResponse(String(id)); +}); + +export const retrieve = spacetimedb.httpHandler((ctx, req) => { + const query = req.uri.split("?", 2)[1] ?? ""; + const idText = query.startsWith("id=") ? query.slice(3) : ""; + const id = BigInt(idText); + const body = ctx.withTx(tx => tx.db.data.id.find(id)?.body); + if (body != null) { + return new SyncResponse(new Uint8Array(body)); + } + return new SyncResponse(null, { status: 404 }); +}); + +export const router = spacetimedb.httpRouter( + new Router().post("/insert", insert).get("/retrieve", retrieve) +); +"#; + +const TS_STRICT_ROOT_ROUTING_MODULE_CODE: &str = r#"import { Router, SyncResponse, schema } from "spacetimedb/server"; + +const spacetimedb = schema({}); +export default spacetimedb; + +export const empty_root = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("empty") +); + +export const slash_root = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("slash") +); + +export const foo = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("foo") +); + +export const foo_slash = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("foo-slash") +); + +export const router = spacetimedb.httpRouter( + new Router() + .get("", empty_root) + .get("/", slash_root) + .get("/foo", foo) + .get("/foo/", foo_slash) +); +"#; + +const TS_STRICT_NON_ROOT_ROUTING_MODULE_CODE: &str = r#"import { Router, SyncResponse, schema } from "spacetimedb/server"; + +const spacetimedb = schema({}); +export default spacetimedb; + +export const foo = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("foo") +); + +export const foo_slash = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("foo-slash") +); + +export const router = spacetimedb.httpRouter( + new Router() + .get("/foo", foo) + .get("/foo/", foo_slash) +); +"#; + +const TS_FULL_URI_MODULE_CODE: &str = r#"import { Router, SyncResponse, schema } from "spacetimedb/server"; + +const spacetimedb = schema({}); +export default spacetimedb; + +export const echo_uri = spacetimedb.httpHandler((_ctx, req) => + new SyncResponse(req.uri) +); + +export const router = spacetimedb.httpRouter( + new Router().get("/echo-uri", echo_uri) +); +"#; + +const TS_HANDLE_REQUEST_BODY_MODULE_CODE: &str = r#"import { Router, SyncResponse, schema } from "spacetimedb/server"; + +const spacetimedb = schema({}); +export default spacetimedb; + +export const reverse_bytes = spacetimedb.httpHandler((_ctx, req) => { + const reversed = req.bytes(); + reversed.reverse(); + return new SyncResponse(reversed); +}); + +export const reverse_words = spacetimedb.httpHandler((_ctx, req) => { + let body; + try { + body = new TextDecoder("utf-8", { fatal: true }).decode(req.bytes()); + } catch { + return new SyncResponse("request body must be valid UTF-8", { status: 400 }); + } + + const reversed = body.split(" ").reverse().join(" "); + return new SyncResponse(reversed); +}); + +export const router = spacetimedb.httpRouter( + new Router() + .post("/reverse-bytes", reverse_bytes) + .post("/reverse-words", reverse_words) +); +"#; + const CS_MODULE_CODE: &str = r#" using System; using System.Collections.Generic; @@ -571,6 +763,13 @@ fn rust_http_test(module_code: &str) -> (Smoketest, String) { (test, identity) } +fn typescript_http_test(name: &str, module_code: &str) -> (Smoketest, String) { + require_pnpm!(); + let mut test = Smoketest::builder().autopublish(false).build(); + let identity = test.publish_typescript_module_source(name, name, module_code).unwrap(); + (test, identity) +} + fn csharp_http_test(name: &str, module_code: &str) -> (Smoketest, String) { let mut test = Smoketest::builder().autopublish(false).build(); let identity = test.publish_csharp_module_source(name, name, module_code).unwrap(); @@ -835,6 +1034,12 @@ fn handle_request_body() { assert_handle_request_body(&test.server_url, &identity); } +#[test] +fn typescript_http_routes_end_to_end() { + let (test, identity) = typescript_http_test("http-routes-typescript-basic", TS_MODULE_CODE); + assert_http_routes_end_to_end(&test.server_url, &identity); +} + #[test] fn csharp_http_routes_end_to_end() { require_dotnet!(); @@ -842,6 +1047,12 @@ fn csharp_http_routes_end_to_end() { assert_http_routes_end_to_end(&test.server_url, &identity); } +#[test] +fn typescript_http_routes_pr_example_round_trip() { + let (test, identity) = typescript_http_test("http-routes-typescript-example", TS_EXAMPLE_MODULE_CODE); + assert_http_routes_pr_example_round_trip(&test.server_url, &identity); +} + #[test] fn csharp_http_routes_pr_example_round_trip() { require_dotnet!(); @@ -849,6 +1060,15 @@ fn csharp_http_routes_pr_example_round_trip() { assert_http_routes_pr_example_round_trip(&test.server_url, &identity); } +#[test] +fn typescript_http_routes_are_strict_for_non_root_paths() { + let (test, identity) = typescript_http_test( + "http-routes-typescript-strict-non-root", + TS_STRICT_NON_ROOT_ROUTING_MODULE_CODE, + ); + assert_http_routes_are_strict_for_non_root_paths(&test.server_url, &identity); +} + #[test] fn csharp_http_routes_are_strict_for_non_root_paths() { require_dotnet!(); @@ -859,6 +1079,13 @@ fn csharp_http_routes_are_strict_for_non_root_paths() { assert_http_routes_are_strict_for_non_root_paths(&test.server_url, &identity); } +#[test] +fn typescript_http_routes_are_strict_for_root_paths() { + let (test, identity) = + typescript_http_test("http-routes-typescript-strict-root", TS_STRICT_ROOT_ROUTING_MODULE_CODE); + assert_http_routes_are_strict_for_root_paths(&test.server_url, &identity); +} + #[test] fn csharp_http_routes_are_strict_for_root_paths() { require_dotnet!(); @@ -866,6 +1093,12 @@ fn csharp_http_routes_are_strict_for_root_paths() { assert_http_routes_are_strict_for_root_paths(&test.server_url, &identity); } +#[test] +fn typescript_http_handler_observes_full_external_uri() { + let (test, identity) = typescript_http_test("http-routes-typescript-full-uri", TS_FULL_URI_MODULE_CODE); + assert_http_handler_observes_full_external_uri(&test.server_url, &identity); +} + #[test] fn csharp_http_handler_observes_full_external_uri() { require_dotnet!(); @@ -873,6 +1106,15 @@ fn csharp_http_handler_observes_full_external_uri() { assert_http_handler_observes_full_external_uri(&test.server_url, &identity); } +#[test] +fn typescript_handle_request_body() { + let (test, identity) = typescript_http_test( + "http-routes-typescript-request-body", + TS_HANDLE_REQUEST_BODY_MODULE_CODE, + ); + assert_handle_request_body(&test.server_url, &identity); +} + #[test] fn csharp_handle_request_body() { require_dotnet!(); @@ -899,6 +1141,33 @@ fn http_handlers_tutorial_say_hello_route_works() { assert_eq!(resp.text().expect("say-hello body"), "Hello!"); } +/// Validates the TypeScript example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. +#[test] +fn typescript_http_handlers_tutorial_say_hello_route_works() { + require_pnpm!(); + + let module_code = extract_code_blocks( + &workspace_root().join("docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md"), + r"```(?:ts|typescript)\n([\s\S]*?)\n```", + "typescript", + ); + let mut test = Smoketest::builder().autopublish(false).build(); + let identity = test + .publish_typescript_module_source( + "http-handlers-docs-typescript", + "http-handlers-docs-typescript", + &module_code, + ) + .unwrap(); + + let url = format!("{}/v1/database/{identity}/route/say-hello", test.server_url); + let client = reqwest::blocking::Client::new(); + + let resp = client.get(&url).send().expect("say-hello failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("say-hello body"), "Hello!"); +} + /// Validates the C# example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. #[test] fn csharp_http_handlers_tutorial_say_hello_route_works() { From f342524832e0da01c3ec45299d98b8754b687673 Mon Sep 17 00:00:00 2001 From: JasonAtClockwork Date: Fri, 22 May 2026 15:00:04 -0700 Subject: [PATCH 44/47] Fix TS router path join for CodeQL alert --- .../src/server/http_handlers.ts | 14 ++++++++++++-- .../tests/http_handlers.test.ts | 8 ++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/bindings-typescript/src/server/http_handlers.ts b/crates/bindings-typescript/src/server/http_handlers.ts index 2772ca88752..a092f513611 100644 --- a/crates/bindings-typescript/src/server/http_handlers.ts +++ b/crates/bindings-typescript/src/server/http_handlers.ts @@ -138,8 +138,18 @@ function joinPaths(prefix: string, suffix: string) { if (suffix === '/') { return prefix; } - const joinedPrefix = prefix.replace(/\/+$/, ''); - const joinedSuffix = suffix.replace(/^\/+/, ''); + let prefixEnd = prefix.length; + while (prefixEnd > 0 && prefix[prefixEnd - 1] === '/') { + prefixEnd--; + } + + let suffixStart = 0; + while (suffixStart < suffix.length && suffix[suffixStart] === '/') { + suffixStart++; + } + + const joinedPrefix = prefix.slice(0, prefixEnd); + const joinedSuffix = suffix.slice(suffixStart); return `${joinedPrefix}/${joinedSuffix}`; } diff --git a/crates/bindings-typescript/tests/http_handlers.test.ts b/crates/bindings-typescript/tests/http_handlers.test.ts index fef87c4d922..7f03100e37f 100644 --- a/crates/bindings-typescript/tests/http_handlers.test.ts +++ b/crates/bindings-typescript/tests/http_handlers.test.ts @@ -199,6 +199,14 @@ describe('http router', async () => { expect(nested.map(route => route.path)).toEqual(['/api/users', '/api']); }); + it('nests paths by collapsing repeated joining slashes', () => { + const nested = new Router() + .nest('/api///', new Router().get('///users', handler())) + .intoRoutes(); + + expect(nested.map(route => route.path)).toEqual(['/api/users']); + }); + it('rejects nesting when an existing route overlaps the nested prefix', () => { expect(() => new Router().get('/api/users', handler()).nest('/api', new Router()) From 6307a68791c1f8bbe994df7764ba4db0d29a095b Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Fri, 22 May 2026 16:54:29 -0700 Subject: [PATCH 45/47] C++ HTTP handlers - Module Bindings (#5023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Adding C++ HTTP handlers based on #4636 - adds the C++ handler/router API `SPACETIMEDB_HTTP_HANDLER()`, `SPACETIMEDB_HTTP_ROUTER()` - wires C++ HTTP handlers into module definition/build/runtime registration - mirrors the Rust/TypeScript HTTP smoketests and docs coverage - update to the the HTTP handlers docs examples # API and ABI breaking changes New APIs for http handler and router behind `SPACETIMEDB_UNSTABLE_FEATURES` and should not be breaking. # Expected complexity level and risk 3 - this touches the C++ binding surface, runtime/module registration, and the smoketest/docs paths, so there is more churn than I’d like, but it is still fairly contained to the C++ HTTP work. # Testing - [x] Expanded the C++ compile-surface tests - [x] Added C++ unit coverage for HTTP conversion helpers - [x] Expanded `crates/smoketests/tests/smoketests/http_routes.rs` with C++ mirrors of the Rust HTTP route tests - [x] Added a C++ docs-example smoketest for `docs/docs/00200-core-concepts/00200-functions/00600-HTTP- handlers.md` I also did some manual testing with a throw away project --------- Co-authored-by: clockwork-labs-bot --- crates/bindings-cpp/CMakeLists.txt | 46 +- crates/bindings-cpp/include/spacetimedb.h | 5 + .../include/spacetimedb/abi/abi.h | 31 ++ .../include/spacetimedb/handler_context.h | 92 ++++ .../bindings-cpp/include/spacetimedb/http.h | 10 +- .../include/spacetimedb/http_client_impl.h | 15 +- .../include/spacetimedb/http_convert.h | 15 + .../include/spacetimedb/http_handler_macros.h | 61 +++ .../include/spacetimedb/http_wire.h | 8 + .../include/spacetimedb/internal/Module.h | 9 + .../internal/autogen/HttpMethod.g.h | 75 ++++ .../internal/autogen/MethodOrAny.g.h | 20 + .../internal/autogen/RawHttpHandlerDefV10.g.h | 26 ++ .../internal/autogen/RawHttpRouteDefV10.g.h | 31 ++ .../autogen/RawMiscModuleExportV9.g.h | 2 +- .../autogen/RawModuleDefV10Section.g.h | 18 +- .../internal/autogen/RawModuleDefV8.g.h | 4 +- .../internal/autogen/RawModuleDefV9.g.h | 6 +- .../internal/autogen/RawProcedureDefV10.g.h | 2 +- .../internal/autogen/RawReducerDefV10.g.h | 4 +- .../internal/autogen/RawTableDefV10.g.h | 6 +- .../internal/autogen/RawTableDefV8.g.h | 4 +- .../internal/autogen/RawTableDefV9.g.h | 6 +- .../internal/runtime_registration.h | 10 + .../spacetimedb/internal/tx_execution.h | 150 +++++++ .../spacetimedb/internal/v10_builder.h | 13 + .../include/spacetimedb/procedure_context.h | 102 +---- .../include/spacetimedb/reducer_context.h | 3 + .../bindings-cpp/include/spacetimedb/router.h | 237 +++++++++++ .../bindings-cpp/src/abi/module_exports.cpp | 19 + crates/bindings-cpp/src/internal/Module.cpp | 70 ++- .../bindings-cpp/src/internal/v10_builder.cpp | 38 ++ .../tests/compile/CMakeLists.module.txt | 49 +++ crates/bindings-cpp/tests/compile/README.md | 64 +++ .../error_http_handler_immutable_ctx.cpp | 12 + .../error_http_handler_no_args.cpp | 12 + .../error_http_handler_no_connection_id.cpp | 13 + .../error_http_handler_no_db.cpp | 19 + .../error_http_handler_no_request_arg.cpp | 12 + .../error_http_handler_no_return_type.cpp | 10 + .../error_http_handler_no_sender.cpp | 13 + .../error_http_handler_wrong_ctx.cpp | 12 + ...or_http_handler_wrong_request_arg_type.cpp | 12 + .../error_http_handler_wrong_return_type.cpp | 7 + .../error_http_router_not_a_function.cpp | 5 + .../error_http_router_with_args.cpp | 16 + .../error_http_router_wrong_return_type.cpp | 7 + .../http-handlers/ok_http_handlers_basic.cpp | 28 ++ .../tests/compile/run-compile-tests.ps1 | 221 ++++++++++ .../tests/compile/run-compile-tests.sh | 209 +++++++++ .../type-isolation-test/CMakeLists.module.txt | 3 +- crates/bindings-cpp/tests/unit/CMakeLists.txt | 38 ++ crates/bindings-cpp/tests/unit/README.md | 52 +++ .../tests/unit/http_unit_tests.cpp | 56 +++ crates/bindings-cpp/tests/unit/main.cpp | 34 ++ .../tests/unit/run-unit-tests.ps1 | 52 +++ .../bindings-cpp/tests/unit/run-unit-tests.sh | 55 +++ crates/bindings-cpp/tests/unit/test_harness.h | 49 +++ crates/codegen/src/cpp.rs | 75 +++- crates/smoketests/src/lib.rs | 106 ++++- .../tests/smoketests/http_routes.rs | 400 +++++++++++++++++- .../00200-functions/00600-HTTP-handlers.md | 47 ++ 62 files changed, 2641 insertions(+), 185 deletions(-) create mode 100644 crates/bindings-cpp/include/spacetimedb/handler_context.h create mode 100644 crates/bindings-cpp/include/spacetimedb/http_handler_macros.h create mode 100644 crates/bindings-cpp/include/spacetimedb/internal/autogen/HttpMethod.g.h create mode 100644 crates/bindings-cpp/include/spacetimedb/internal/autogen/MethodOrAny.g.h create mode 100644 crates/bindings-cpp/include/spacetimedb/internal/autogen/RawHttpHandlerDefV10.g.h create mode 100644 crates/bindings-cpp/include/spacetimedb/internal/autogen/RawHttpRouteDefV10.g.h create mode 100644 crates/bindings-cpp/include/spacetimedb/internal/tx_execution.h create mode 100644 crates/bindings-cpp/include/spacetimedb/router.h create mode 100644 crates/bindings-cpp/tests/compile/CMakeLists.module.txt create mode 100644 crates/bindings-cpp/tests/compile/README.md create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_immutable_ctx.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_args.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_connection_id.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_db.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_request_arg.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_return_type.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_sender.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_ctx.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_request_arg_type.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_return_type.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_not_a_function.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_with_args.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_wrong_return_type.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/ok_http_handlers_basic.cpp create mode 100644 crates/bindings-cpp/tests/compile/run-compile-tests.ps1 create mode 100644 crates/bindings-cpp/tests/compile/run-compile-tests.sh create mode 100644 crates/bindings-cpp/tests/unit/CMakeLists.txt create mode 100644 crates/bindings-cpp/tests/unit/README.md create mode 100644 crates/bindings-cpp/tests/unit/http_unit_tests.cpp create mode 100644 crates/bindings-cpp/tests/unit/main.cpp create mode 100644 crates/bindings-cpp/tests/unit/run-unit-tests.ps1 create mode 100644 crates/bindings-cpp/tests/unit/run-unit-tests.sh create mode 100644 crates/bindings-cpp/tests/unit/test_harness.h diff --git a/crates/bindings-cpp/CMakeLists.txt b/crates/bindings-cpp/CMakeLists.txt index 35a4e0a1701..a66b50ad9e2 100644 --- a/crates/bindings-cpp/CMakeLists.txt +++ b/crates/bindings-cpp/CMakeLists.txt @@ -30,6 +30,7 @@ target_sources(spacetimedb_cpp_library PRIVATE ${LIBRARY_SOURCES}) # Require C++20 for consumers of this library without forcing global flags target_compile_features(spacetimedb_cpp_library PUBLIC cxx_std_20) +target_compile_definitions(spacetimedb_cpp_library PRIVATE SPACETIMEDB_UNSTABLE_FEATURES) # Set include directories target_include_directories(spacetimedb_cpp_library @@ -60,46 +61,5 @@ if(PROJECT_IS_TOP_LEVEL) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) endif() -# ---- Tests ---- -# Default: ON only when building this project directly; OFF when used via FetchContent/add_subdirectory -if(CMAKE_VERSION VERSION_LESS 3.21) - # Fallback heuristic for older CMake - set(_is_top_level FALSE) - if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) - set(_is_top_level TRUE) - endif() -else() - set(_is_top_level ${PROJECT_IS_TOP_LEVEL}) -endif() - -option(BUILD_TESTS "Build the test suite" ${_is_top_level}) - -if(BUILD_TESTS AND NOT CMAKE_SYSTEM_NAME STREQUAL "Emscripten") - enable_testing() - - # Add test executable - add_executable(test_bsatn tests/main.cpp tests/module_library_unit_tests.cpp) - - # Link against the module library - target_link_libraries(test_bsatn PRIVATE spacetimedb_cpp_library) - - # Set C++20 standard for tests - target_compile_features(test_bsatn PRIVATE cxx_std_20) - - # Add test to CTest - add_test(NAME bsatn_tests COMMAND test_bsatn) - - # Add verbose test variant - add_test(NAME bsatn_tests_verbose COMMAND test_bsatn -v) - - # Set test properties - set_tests_properties(bsatn_tests PROPERTIES - TIMEOUT 30 - LABELS "unit" - ) - - set_tests_properties(bsatn_tests_verbose PROPERTIES - TIMEOUT 30 - LABELS "unit;verbose" - ) -endif() +# Unit/compile/smoke test harnesses live under `tests/` as standalone runners +# rather than being built through the top-level library CMake target. diff --git a/crates/bindings-cpp/include/spacetimedb.h b/crates/bindings-cpp/include/spacetimedb.h index a36d96074a8..4ed389d06fa 100644 --- a/crates/bindings-cpp/include/spacetimedb.h +++ b/crates/bindings-cpp/include/spacetimedb.h @@ -126,6 +126,11 @@ // Procedure context and macros #include "spacetimedb/procedure_macros.h" +#ifdef SPACETIMEDB_UNSTABLE_FEATURES +#include "spacetimedb/handler_context.h" +#include "spacetimedb/router.h" +#include "spacetimedb/http_handler_macros.h" +#endif // ============================================================================= // VIEW SYSTEM diff --git a/crates/bindings-cpp/include/spacetimedb/abi/abi.h b/crates/bindings-cpp/include/spacetimedb/abi/abi.h index 54cefc8e9c3..e1aa12ac2de 100644 --- a/crates/bindings-cpp/include/spacetimedb/abi/abi.h +++ b/crates/bindings-cpp/include/spacetimedb/abi/abi.h @@ -216,6 +216,37 @@ int16_t __call_reducer__( BytesSource args, BytesSink error); +STDB_EXPORT(__call_view__) +int16_t __call_view__( + uint32_t id, + uint64_t sender_0, uint64_t sender_1, uint64_t sender_2, uint64_t sender_3, + BytesSource args, + BytesSink result); + +STDB_EXPORT(__call_view_anon__) +int16_t __call_view_anon__( + uint32_t id, + BytesSource args, + BytesSink result); + +STDB_EXPORT(__call_procedure__) +int16_t __call_procedure__( + uint32_t id, + uint64_t sender_0, uint64_t sender_1, uint64_t sender_2, uint64_t sender_3, + uint64_t conn_id_0, uint64_t conn_id_1, + uint64_t timestamp_microseconds, + BytesSource args_source, + BytesSink result_sink); + +STDB_EXPORT(__call_http_handler__) +int16_t __call_http_handler__( + uint32_t id, + uint64_t timestamp_microseconds, + BytesSource request_source, + BytesSource request_body_source, + BytesSink response_sink, + BytesSink response_body_sink); + // ======================================================================== // WASI SHIMS // ======================================================================== diff --git a/crates/bindings-cpp/include/spacetimedb/handler_context.h b/crates/bindings-cpp/include/spacetimedb/handler_context.h new file mode 100644 index 00000000000..cb9b4abe84c --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/handler_context.h @@ -0,0 +1,92 @@ +#ifndef SPACETIMEDB_HANDLER_CONTEXT_H +#define SPACETIMEDB_HANDLER_CONTEXT_H + +#ifndef SPACETIMEDB_UNSTABLE_FEATURES +#error "spacetimedb/handler_context.h requires SPACETIMEDB_UNSTABLE_FEATURES to be enabled" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace SpacetimeDB { + +struct HandlerContext { + Timestamp timestamp; + HttpClient http; + +private: + mutable std::shared_ptr rng_instance; + mutable uint32_t counter_uuid_ = 0; + +public: + HandlerContext() = default; + explicit HandlerContext(Timestamp t) : timestamp(t) {} + + Identity identity() const { + std::array id_bytes; + ::identity(id_bytes.data()); + return Identity(id_bytes); + } + + StdbRng& rng() const { + if (!rng_instance) { + rng_instance = std::make_shared(timestamp); + } + return *rng_instance; + } + + Uuid new_uuid_v4() const { + std::array random_bytes; + rng().fill_bytes(random_bytes.data(), random_bytes.size()); + return Uuid::from_random_bytes_v4(random_bytes); + } + + Uuid new_uuid_v7() const { + std::array random_bytes; + rng().fill_bytes(random_bytes.data(), random_bytes.size()); + return Uuid::from_counter_v7(counter_uuid_, timestamp, random_bytes); + } + +#ifdef SPACETIMEDB_UNSTABLE_FEATURES + template + auto with_tx(Func&& body) -> decltype(body(std::declval())) { + auto make_reducer_ctx = [](Timestamp tx_timestamp) { + return ReducerContext( + Identity{}, + std::nullopt, + tx_timestamp, + AuthCtx::internal() + ); + }; + return Internal::with_tx(make_reducer_ctx, body); + } + + template + auto try_with_tx(Func&& body) -> decltype(body(std::declval())) { + auto make_reducer_ctx = [](Timestamp tx_timestamp) { + return ReducerContext( + Identity{}, + std::nullopt, + tx_timestamp, + AuthCtx::internal() + ); + }; + return Internal::try_with_tx(make_reducer_ctx, body); + } +#endif +}; + +} // namespace SpacetimeDB + +#endif // SPACETIMEDB_HANDLER_CONTEXT_H diff --git a/crates/bindings-cpp/include/spacetimedb/http.h b/crates/bindings-cpp/include/spacetimedb/http.h index cb51acafa0a..82a55beeb32 100644 --- a/crates/bindings-cpp/include/spacetimedb/http.h +++ b/crates/bindings-cpp/include/spacetimedb/http.h @@ -3,6 +3,10 @@ #pragma once +#ifndef SPACETIMEDB_UNSTABLE_FEATURES +#error "spacetimedb/http.h requires SPACETIMEDB_UNSTABLE_FEATURES to be enabled" +#endif + #include #include #include @@ -312,8 +316,10 @@ class HttpClient { } // namespace SpacetimeDB -// Include implementation after class definition to avoid circular dependencies -#ifdef SPACETIMEDB_UNSTABLE_FEATURES +// Include implementation dependencies after class definition to avoid circular dependencies +#if defined(SPACETIMEDB_UNSTABLE_FEATURES) && !defined(SPACETIMEDB_HTTP_CONVERT_H) +#include "spacetimedb/logger.h" +#include "spacetimedb/http_convert.h" #include "spacetimedb/http_client_impl.h" #endif diff --git a/crates/bindings-cpp/include/spacetimedb/http_client_impl.h b/crates/bindings-cpp/include/spacetimedb/http_client_impl.h index ff3e0faf3df..e1ad619410b 100644 --- a/crates/bindings-cpp/include/spacetimedb/http_client_impl.h +++ b/crates/bindings-cpp/include/spacetimedb/http_client_impl.h @@ -7,7 +7,7 @@ #include "spacetimedb/http_convert.h" #include "spacetimedb/abi/abi.h" #include "spacetimedb/bsatn/bsatn.h" -#include "spacetimedb/internal/Module.h" +#include "spacetimedb/internal/runtime_registration.h" namespace SpacetimeDB { @@ -23,9 +23,9 @@ inline Outcome HttpClient::SendImpl(const HttpRequest& request) { // Prepare body bytes const std::vector& body_bytes = request.body.bytes; - // Call host function - // Note: For empty body, we need to pass a valid pointer, not null - const uint8_t* body_ptr = body_bytes.empty() ? reinterpret_cast("") : body_bytes.data(); + // The host ABI requires a non-null, in-bounds body pointer even when body_len == 0. + static const uint8_t empty_sentinel = 0; + const uint8_t* body_ptr = body_bytes.empty() ? &empty_sentinel : body_bytes.data(); BytesSource out[2] = {BytesSource{0}, BytesSource{0}}; Status status = procedure_http_request( @@ -40,15 +40,11 @@ inline Outcome HttpClient::SendImpl(const HttpRequest& request) { if (status.inner == 21) { // Read error message from out[0] std::vector error_bytes = Internal::ConsumeBytes(out[0]); - - LOG_INFO("HTTP: Error bytes: " + std::to_string(error_bytes.size())); - + // Decode BSATN string bsatn::Reader reader(error_bytes.data(), error_bytes.size()); std::string error_message = bsatn::deserialize(reader); - LOG_INFO("HTTP: Error message: " + error_message); - return Err(std::move(error_message)); } @@ -57,7 +53,6 @@ inline Outcome HttpClient::SendImpl(const HttpRequest& request) { return Err("HTTP requests are blocked inside transactions. Call HTTP before with_tx() or try_with_tx()."); } - LOG_INFO("HTTP: Unknown error code: " + std::to_string(status.inner)); return Err("HTTP request failed with status code: " + std::to_string(status.inner)); } diff --git a/crates/bindings-cpp/include/spacetimedb/http_convert.h b/crates/bindings-cpp/include/spacetimedb/http_convert.h index b479cf84d03..e514f4b864e 100644 --- a/crates/bindings-cpp/include/spacetimedb/http_convert.h +++ b/crates/bindings-cpp/include/spacetimedb/http_convert.h @@ -237,6 +237,12 @@ inline HttpRequest from_wire(const wire::HttpRequest& request) { return result; } +inline HttpRequest from_wire(const wire::HttpRequest& request, std::vector body) { + HttpRequest result = from_wire(request); + result.body.bytes = std::move(body); + return result; +} + // ==================== HttpResponse Conversions ==================== /** @@ -268,7 +274,16 @@ inline HttpResponse from_wire(const wire::HttpResponse& response) { return result; } +inline std::pair> to_wire_split(const HttpResponse& response) { + return {to_wire(response), response.body.bytes}; +} + } // namespace convert } // namespace SpacetimeDB +#ifdef SPACETIMEDB_UNSTABLE_FEATURES +#include "spacetimedb/logger.h" +#include "spacetimedb/http_client_impl.h" +#endif + #endif // SPACETIMEDB_HTTP_CONVERT_H diff --git a/crates/bindings-cpp/include/spacetimedb/http_handler_macros.h b/crates/bindings-cpp/include/spacetimedb/http_handler_macros.h new file mode 100644 index 00000000000..4cd88802f9f --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/http_handler_macros.h @@ -0,0 +1,61 @@ +#pragma once + +#ifndef SPACETIMEDB_UNSTABLE_FEATURES +#error "spacetimedb/http_handler_macros.h requires SPACETIMEDB_UNSTABLE_FEATURES to be enabled" +#endif + +#include "spacetimedb/handler_context.h" +#include "spacetimedb/http.h" +#include "spacetimedb/internal/runtime_registration.h" +#include "spacetimedb/internal/template_utils.h" +#include "spacetimedb/internal/v10_builder.h" +#include "spacetimedb/macros.h" +#include "spacetimedb/router.h" + +namespace SpacetimeDB::Internal { + +template +inline void RegisterHttpHandlerMacro(const char* handler_name, Func func) { + using traits = function_traits; + static_assert(traits::arity == 2, "HTTP handlers must take exactly two arguments"); + using ContextType = typename traits::template arg_t<0>; + using RequestType = typename traits::template arg_t<1>; + using ReturnType = typename traits::result_type; + static_assert(std::is_same_v, "First parameter of HTTP handler must be HandlerContext"); + static_assert(std::is_same_v, "Second parameter of HTTP handler must be HttpRequest"); + static_assert(std::is_same_v, "HTTP handlers must return HttpResponse"); + + std::function handler = + [func](HandlerContext& ctx, HttpRequest request) -> HttpResponse { + return func(ctx, std::move(request)); + }; + RegisterHttpHandlerHandler(handler_name, func, std::move(handler)); + getV10Builder().RegisterHttpHandlerDef(handler_name); +} + +} // namespace SpacetimeDB::Internal + +#define SPACETIMEDB_HTTP_HANDLER(handler_name, ctx_param, request_param) \ + SpacetimeDB::HttpResponse handler_name(ctx_param, request_param); \ + __attribute__((export_name("__preinit__60_http_handler_" #handler_name))) \ + extern "C" void CONCAT(_spacetimedb_preinit_register_http_handler_, handler_name)() { \ + ::SpacetimeDB::Internal::RegisterHttpHandlerMacro(#handler_name, handler_name); \ + } \ + SpacetimeDB::HttpResponse handler_name(ctx_param, request_param) + +#define SPACETIMEDB_HTTP_HANDLER_NAMED(handler_name, canonical_name, ctx_param, request_param) \ + SpacetimeDB::HttpResponse handler_name(ctx_param, request_param); \ + __attribute__((export_name("__preinit__60_http_handler_" #handler_name))) \ + extern "C" void CONCAT(_spacetimedb_preinit_register_http_handler_, handler_name)() { \ + ::SpacetimeDB::Internal::RegisterHttpHandlerMacro(#handler_name, handler_name); \ + SpacetimeDB::Module::RegisterExplicitFunctionName(#handler_name, canonical_name); \ + } \ + SpacetimeDB::HttpResponse handler_name(ctx_param, request_param) + +#define SPACETIMEDB_HTTP_ROUTER(router_name) \ + SpacetimeDB::Router router_name(); \ + __attribute__((export_name("__preinit__61_http_router_" #router_name))) \ + extern "C" void CONCAT(_spacetimedb_preinit_register_http_router_, router_name)() { \ + ::SpacetimeDB::Internal::getV10Builder().RegisterHttpRouter(router_name()); \ + } \ + SpacetimeDB::Router router_name() diff --git a/crates/bindings-cpp/include/spacetimedb/http_wire.h b/crates/bindings-cpp/include/spacetimedb/http_wire.h index c4631a749aa..ae473512a37 100644 --- a/crates/bindings-cpp/include/spacetimedb/http_wire.h +++ b/crates/bindings-cpp/include/spacetimedb/http_wire.h @@ -59,6 +59,14 @@ struct HttpMethod { std::string extension; // Only valid when tag == Extension }; +inline bool operator==(const HttpMethod& lhs, const HttpMethod& rhs) { + return lhs.tag == rhs.tag && lhs.extension == rhs.extension; +} + +inline bool operator!=(const HttpMethod& lhs, const HttpMethod& rhs) { + return !(lhs == rhs); +} + /** * @brief Wire format for HTTP version * diff --git a/crates/bindings-cpp/include/spacetimedb/internal/Module.h b/crates/bindings-cpp/include/spacetimedb/internal/Module.h index dd27c18dc3a..7e02e858fb6 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/Module.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/Module.h @@ -75,6 +75,15 @@ class Module { BytesSource args_source, BytesSink result_sink ); + + static int16_t __call_http_handler__( + uint32_t id, + uint64_t timestamp_microseconds, + BytesSource request_source, + BytesSource request_body_source, + BytesSink response_sink, + BytesSink response_body_sink + ); // Internal registration methods (inline to avoid linking issues) template diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/HttpMethod.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/HttpMethod.g.h new file mode 100644 index 00000000000..bb53d566b39 --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/HttpMethod.g.h @@ -0,0 +1,75 @@ +// 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 codegen. + +#pragma once + +#include +#include +#include +#include +#include +#include "../autogen_base.h" +#include "spacetimedb/bsatn/bsatn.h" + +namespace SpacetimeDB::Internal { + +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(HttpMethod_Head_Wrapper) { + std::monostate value; + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, value); + } + SPACETIMEDB_PRODUCT_TYPE_EQUALITY(value) +}; +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(HttpMethod_Post_Wrapper) { + std::monostate value; + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, value); + } + SPACETIMEDB_PRODUCT_TYPE_EQUALITY(value) +}; +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(HttpMethod_Put_Wrapper) { + std::monostate value; + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, value); + } + SPACETIMEDB_PRODUCT_TYPE_EQUALITY(value) +}; +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(HttpMethod_Delete_Wrapper) { + std::monostate value; + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, value); + } + SPACETIMEDB_PRODUCT_TYPE_EQUALITY(value) +}; +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(HttpMethod_Connect_Wrapper) { + std::monostate value; + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, value); + } + SPACETIMEDB_PRODUCT_TYPE_EQUALITY(value) +}; +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(HttpMethod_Options_Wrapper) { + std::monostate value; + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, value); + } + SPACETIMEDB_PRODUCT_TYPE_EQUALITY(value) +}; +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(HttpMethod_Trace_Wrapper) { + std::monostate value; + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, value); + } + SPACETIMEDB_PRODUCT_TYPE_EQUALITY(value) +}; +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(HttpMethod_Patch_Wrapper) { + std::monostate value; + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, value); + } + SPACETIMEDB_PRODUCT_TYPE_EQUALITY(value) +}; +SPACETIMEDB_INTERNAL_TAGGED_ENUM(HttpMethod, std::monostate, HttpMethod_Head_Wrapper, HttpMethod_Post_Wrapper, HttpMethod_Put_Wrapper, HttpMethod_Delete_Wrapper, HttpMethod_Connect_Wrapper, HttpMethod_Options_Wrapper, HttpMethod_Trace_Wrapper, HttpMethod_Patch_Wrapper, std::string) +} // namespace SpacetimeDB::Internal diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/MethodOrAny.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/MethodOrAny.g.h new file mode 100644 index 00000000000..347702f1a61 --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/MethodOrAny.g.h @@ -0,0 +1,20 @@ +// 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 codegen. + +#pragma once + +#include +#include +#include +#include +#include +#include "../autogen_base.h" +#include "spacetimedb/bsatn/bsatn.h" +#include "HttpMethod.g.h" + +namespace SpacetimeDB::Internal { + +SPACETIMEDB_INTERNAL_TAGGED_ENUM(MethodOrAny, std::monostate, SpacetimeDB::Internal::HttpMethod) +} // namespace SpacetimeDB::Internal diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawHttpHandlerDefV10.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawHttpHandlerDefV10.g.h new file mode 100644 index 00000000000..b6495235e7b --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawHttpHandlerDefV10.g.h @@ -0,0 +1,26 @@ +// 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 codegen. + +#pragma once + +#include +#include +#include +#include +#include +#include "../autogen_base.h" +#include "spacetimedb/bsatn/bsatn.h" + +namespace SpacetimeDB::Internal { + +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(RawHttpHandlerDefV10) { + std::string source_name; + + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, source_name); + } + SPACETIMEDB_PRODUCT_TYPE_EQUALITY(source_name) +}; +} // namespace SpacetimeDB::Internal diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawHttpRouteDefV10.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawHttpRouteDefV10.g.h new file mode 100644 index 00000000000..791882e6dff --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawHttpRouteDefV10.g.h @@ -0,0 +1,31 @@ +// 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 codegen. + +#pragma once + +#include +#include +#include +#include +#include +#include "../autogen_base.h" +#include "spacetimedb/bsatn/bsatn.h" +#include "MethodOrAny.g.h" + +namespace SpacetimeDB::Internal { + +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(RawHttpRouteDefV10) { + std::string handler_function; + SpacetimeDB::Internal::MethodOrAny method; + std::string path; + + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, handler_function); + ::SpacetimeDB::bsatn::serialize(writer, method); + ::SpacetimeDB::bsatn::serialize(writer, path); + } + SPACETIMEDB_PRODUCT_TYPE_EQUALITY(handler_function, method, path) +}; +} // namespace SpacetimeDB::Internal diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawMiscModuleExportV9.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawMiscModuleExportV9.g.h index b8e391591df..494243dd470 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawMiscModuleExportV9.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawMiscModuleExportV9.g.h @@ -12,9 +12,9 @@ #include #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" -#include "RawViewDefV9.g.h" #include "RawColumnDefaultValueV9.g.h" #include "RawProcedureDefV9.g.h" +#include "RawViewDefV9.g.h" namespace SpacetimeDB::Internal { diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h index 241466f467c..1efcad29ed5 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h @@ -12,19 +12,21 @@ #include #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" -#include "RawViewDefV10.g.h" +#include "RawProcedureDefV10.g.h" #include "CaseConversionPolicy.g.h" -#include "RawScheduleDefV10.g.h" -#include "RawTableDefV10.g.h" -#include "Typespace.g.h" +#include "RawLifeCycleReducerDefV10.g.h" #include "RawReducerDefV10.g.h" -#include "RawProcedureDefV10.g.h" +#include "RawHttpHandlerDefV10.g.h" #include "RawTypeDefV10.g.h" -#include "RawLifeCycleReducerDefV10.g.h" -#include "RawRowLevelSecurityDefV9.g.h" #include "ExplicitNames.g.h" +#include "RawViewDefV10.g.h" +#include "RawScheduleDefV10.g.h" +#include "Typespace.g.h" +#include "RawTableDefV10.g.h" +#include "RawRowLevelSecurityDefV9.g.h" +#include "RawHttpRouteDefV10.g.h" namespace SpacetimeDB::Internal { -SPACETIMEDB_INTERNAL_TAGGED_ENUM(RawModuleDefV10Section, SpacetimeDB::Internal::Typespace, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, SpacetimeDB::Internal::CaseConversionPolicy, SpacetimeDB::Internal::ExplicitNames) +SPACETIMEDB_INTERNAL_TAGGED_ENUM(RawModuleDefV10Section, SpacetimeDB::Internal::Typespace, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, SpacetimeDB::Internal::CaseConversionPolicy, SpacetimeDB::Internal::ExplicitNames, std::vector, std::vector) } // namespace SpacetimeDB::Internal diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV8.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV8.g.h index e856af0fec5..6936f2f32c5 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV8.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV8.g.h @@ -12,10 +12,10 @@ #include #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" -#include "ReducerDef.g.h" #include "MiscModuleExport.g.h" -#include "Typespace.g.h" +#include "ReducerDef.g.h" #include "TableDesc.g.h" +#include "Typespace.g.h" namespace SpacetimeDB::Internal { diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV9.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV9.g.h index 9ab21147e08..cf6881a9bb2 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV9.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV9.g.h @@ -13,11 +13,11 @@ #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" #include "RawTableDefV9.g.h" -#include "RawTypeDefV9.g.h" -#include "RawMiscModuleExportV9.g.h" +#include "RawRowLevelSecurityDefV9.g.h" #include "Typespace.g.h" #include "RawReducerDefV9.g.h" -#include "RawRowLevelSecurityDefV9.g.h" +#include "RawTypeDefV9.g.h" +#include "RawMiscModuleExportV9.g.h" namespace SpacetimeDB::Internal { diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawProcedureDefV10.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawProcedureDefV10.g.h index f316264fc5c..dc84b35e602 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawProcedureDefV10.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawProcedureDefV10.g.h @@ -12,9 +12,9 @@ #include #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" +#include "AlgebraicType.g.h" #include "FunctionVisibility.g.h" #include "ProductType.g.h" -#include "AlgebraicType.g.h" namespace SpacetimeDB::Internal { diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawReducerDefV10.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawReducerDefV10.g.h index 89934c2d4d7..c2ea7a04c30 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawReducerDefV10.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawReducerDefV10.g.h @@ -12,9 +12,9 @@ #include #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" -#include "AlgebraicType.g.h" -#include "FunctionVisibility.g.h" #include "ProductType.g.h" +#include "FunctionVisibility.g.h" +#include "AlgebraicType.g.h" namespace SpacetimeDB::Internal { diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawTableDefV10.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawTableDefV10.g.h index 715364b13cf..46fc7ca6ed1 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawTableDefV10.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawTableDefV10.g.h @@ -12,12 +12,12 @@ #include #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" +#include "TableAccess.g.h" +#include "RawSequenceDefV10.g.h" +#include "RawConstraintDefV10.g.h" #include "RawIndexDefV10.g.h" #include "TableType.g.h" -#include "TableAccess.g.h" #include "RawColumnDefaultValueV10.g.h" -#include "RawConstraintDefV10.g.h" -#include "RawSequenceDefV10.g.h" namespace SpacetimeDB::Internal { diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawTableDefV8.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawTableDefV8.g.h index a985ad2f6e7..4a85aabd2f3 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawTableDefV8.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawTableDefV8.g.h @@ -12,10 +12,10 @@ #include #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" +#include "RawIndexDefV8.g.h" #include "RawSequenceDefV8.g.h" -#include "RawColumnDefV8.g.h" #include "RawConstraintDefV8.g.h" -#include "RawIndexDefV8.g.h" +#include "RawColumnDefV8.g.h" namespace SpacetimeDB::Internal { diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawTableDefV9.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawTableDefV9.g.h index a69a502fb0e..e817785f690 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawTableDefV9.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawTableDefV9.g.h @@ -12,12 +12,12 @@ #include #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" -#include "RawScheduleDefV9.g.h" -#include "RawSequenceDefV9.g.h" #include "TableType.g.h" +#include "RawSequenceDefV9.g.h" #include "TableAccess.g.h" -#include "RawConstraintDefV9.g.h" #include "RawIndexDefV9.g.h" +#include "RawConstraintDefV9.g.h" +#include "RawScheduleDefV9.g.h" namespace SpacetimeDB::Internal { diff --git a/crates/bindings-cpp/include/spacetimedb/internal/runtime_registration.h b/crates/bindings-cpp/include/spacetimedb/internal/runtime_registration.h index 4d84cb975d4..005a6553172 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/runtime_registration.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/runtime_registration.h @@ -14,9 +14,14 @@ struct ReducerContext; struct ViewContext; struct AnonymousViewContext; struct ProcedureContext; +struct HandlerContext; +struct HttpRequest; +struct HttpResponse; namespace Internal { +using HttpHandlerSymbol = HttpResponse (*)(HandlerContext, HttpRequest); + void RegisterReducerHandler(const std::string& name, std::function handler, std::optional lifecycle = std::nullopt); @@ -26,9 +31,14 @@ void RegisterAnonymousViewHandler(const std::string& name, std::function(AnonymousViewContext&, BytesSource)> handler); void RegisterProcedureHandler(const std::string& name, std::function(ProcedureContext&, BytesSource)> handler); +void RegisterHttpHandlerHandler(const std::string& name, + HttpHandlerSymbol handler_symbol, + std::function handler); +std::string LookupHttpHandlerName(HttpHandlerSymbol handler_symbol); size_t GetViewHandlerCount(); size_t GetAnonymousViewHandlerCount(); size_t GetProcedureHandlerCount(); +size_t GetHttpHandlerCount(); std::vector ConsumeBytes(BytesSource source); void SetMultiplePrimaryKeyError(const std::string& table_name); void SetConstraintRegistrationError(const std::string& code, const std::string& details); diff --git a/crates/bindings-cpp/include/spacetimedb/internal/tx_execution.h b/crates/bindings-cpp/include/spacetimedb/internal/tx_execution.h new file mode 100644 index 00000000000..4f0f8db2d0c --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/internal/tx_execution.h @@ -0,0 +1,150 @@ +#ifndef SPACETIMEDB_INTERNAL_TX_EXECUTION_H +#define SPACETIMEDB_INTERNAL_TX_EXECUTION_H + +#include +#include +#include +#include +#include + +namespace SpacetimeDB::Internal { + +#ifdef SPACETIMEDB_UNSTABLE_FEATURES + +template +struct is_outcome : std::false_type {}; + +template +struct is_outcome> : std::true_type {}; + +template +inline constexpr bool is_outcome_v = is_outcome>>::value; + +template +bool tx_result_should_commit(const T& result) { + using ResultType = std::remove_cv_t>; + // TODO(http-handlers-cpp): Consider tightening try_with_tx in a future breaking release + // so rollback-aware callbacks use Outcome (and possibly bool for compatibility) + // instead of silently treating arbitrary return types as commit-on-success. + if constexpr (std::is_same_v) { + return result; + } else if constexpr (is_outcome_v) { + return result.is_ok(); + } else { + return true; + } +} + +class TxAbortGuard { +public: + TxAbortGuard() = default; + TxAbortGuard(const TxAbortGuard&) = delete; + TxAbortGuard& operator=(const TxAbortGuard&) = delete; + + ~TxAbortGuard() { + if (!armed_) { + return; + } + Status status = FFI::procedure_abort_mut_tx(); + if (is_error(status)) { + LOG_PANIC("Failed to abort transaction"); + } + } + + void disarm() { + armed_ = false; + } + +private: + bool armed_ = true; +}; + +inline void commit_tx_or_panic() { + Status status = FFI::procedure_commit_mut_tx(); + if (is_error(status)) { + LOG_PANIC("Failed to commit transaction"); + } +} + +inline bool try_commit_tx() { + return is_ok(FFI::procedure_commit_mut_tx()); +} + +inline void abort_tx_or_panic() { + Status status = FFI::procedure_abort_mut_tx(); + if (is_error(status)) { + LOG_PANIC("Failed to abort transaction"); + } +} + +template +auto run_tx_once(MakeReducerContext&& make_reducer_ctx, Func& body) -> decltype(body(std::declval())) { + using ResultType = decltype(body(std::declval())); + + int64_t tx_timestamp = 0; + Status status = FFI::procedure_start_mut_tx(&tx_timestamp); + if (is_error(status)) { + LOG_PANIC("Failed to start transaction"); + } + + TxAbortGuard abort_guard; + ReducerContext reducer_ctx = make_reducer_ctx(Timestamp::from_micros_since_epoch(tx_timestamp)); + TxContext tx{reducer_ctx}; + + if constexpr (std::is_void_v) { + body(tx); + abort_guard.disarm(); + } else { + ResultType result = body(tx); + abort_guard.disarm(); + return result; + } +} + +template +auto with_tx(MakeReducerContext&& make_reducer_ctx, Func& body) -> decltype(body(std::declval())) { + using ResultType = decltype(body(std::declval())); + + if constexpr (std::is_void_v) { + run_tx_once(std::forward(make_reducer_ctx), body); + if (!try_commit_tx()) { + run_tx_once(std::forward(make_reducer_ctx), body); + commit_tx_or_panic(); + } + } else { + ResultType result = run_tx_once(std::forward(make_reducer_ctx), body); + if (!try_commit_tx()) { + result = run_tx_once(std::forward(make_reducer_ctx), body); + commit_tx_or_panic(); + } + return result; + } +} + +template +auto try_with_tx(MakeReducerContext&& make_reducer_ctx, Func& body) -> decltype(body(std::declval())) { + using ResultType = decltype(body(std::declval())); + + ResultType result = run_tx_once(std::forward(make_reducer_ctx), body); + if (!tx_result_should_commit(result)) { + abort_tx_or_panic(); + return result; + } + + if (!try_commit_tx()) { + result = run_tx_once(std::forward(make_reducer_ctx), body); + if (tx_result_should_commit(result)) { + commit_tx_or_panic(); + } else { + abort_tx_or_panic(); + } + } + + return result; +} + +#endif + +} // namespace SpacetimeDB::Internal + +#endif // SPACETIMEDB_INTERNAL_TX_EXECUTION_H diff --git a/crates/bindings-cpp/include/spacetimedb/internal/v10_builder.h b/crates/bindings-cpp/include/spacetimedb/internal/v10_builder.h index 9de0f0a2312..f746093c574 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/v10_builder.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/v10_builder.h @@ -28,6 +28,8 @@ #include "autogen/RawViewDefV10.g.h" #include "autogen/RawScheduleDefV10.g.h" #include "autogen/RawLifeCycleReducerDefV10.g.h" +#include "autogen/RawHttpHandlerDefV10.g.h" +#include "autogen/RawHttpRouteDefV10.g.h" #include "autogen/RawColumnDefaultValueV10.g.h" #include "autogen/RawRowLevelSecurityDefV9.g.h" #include "autogen/RawTypeDefV10.g.h" @@ -39,6 +41,8 @@ namespace SpacetimeDB { +class Router; + void fail_reducer(std::string message); namespace Internal { @@ -584,6 +588,10 @@ class V10Builder { UpsertProcedure(procedure_def); } + void RegisterHttpHandlerDef(const std::string& handler_name); + void RegisterHttpRoute(const RawHttpRouteDefV10& route); + void RegisterHttpRouter(const ::SpacetimeDB::Router& router); + void RegisterSchedule(const std::string& table_name, uint16_t scheduled_at_column, const std::string& reducer_name) { if (g_circular_ref_error) { std::fprintf(stderr, "ERROR: Skipping schedule registration for table '%s' because circular reference error is set\n", @@ -628,6 +636,8 @@ class V10Builder { const std::vector& GetReducers() const { return reducers_; } const std::optional& GetCaseConversionPolicy() const { return case_conversion_policy_; } const std::vector& GetExplicitNames() const { return explicit_names_; } + const std::vector& GetHttpHandlers() const { return http_handlers_; } + const std::vector& GetHttpRoutes() const { return http_routes_; } private: std::vector::iterator FindTable(const std::string& table_name) { @@ -638,6 +648,7 @@ class V10Builder { void UpsertReducer(const RawReducerDefV10& reducer); void UpsertProcedure(const RawProcedureDefV10& procedure); void UpsertView(const RawViewDefV10& view); + void UpsertHttpHandler(const RawHttpHandlerDefV10& handler); RawIndexDefV10 CreateBTreeIndex(const std::string& table_name, const std::string& source_name, const std::vector& columns, @@ -656,6 +667,8 @@ class V10Builder { std::vector reducers_; std::vector procedures_; std::vector views_; + std::vector http_handlers_; + std::vector http_routes_; std::vector schedules_; std::vector lifecycle_reducers_; std::vector row_level_security_; diff --git a/crates/bindings-cpp/include/spacetimedb/procedure_context.h b/crates/bindings-cpp/include/spacetimedb/procedure_context.h index f9107d70251..ea189f8ef12 100644 --- a/crates/bindings-cpp/include/spacetimedb/procedure_context.h +++ b/crates/bindings-cpp/include/spacetimedb/procedure_context.h @@ -6,6 +6,7 @@ #include // For Uuid #include // For TxContext #include // For transaction syscalls +#include #include // For StdbRng #ifdef SPACETIMEDB_UNSTABLE_FEATURES #include // For HttpClient @@ -196,46 +197,14 @@ struct ProcedureContext { */ template auto with_tx(Func&& body) -> decltype(body(std::declval())) { - using ResultType = decltype(body(std::declval())); - - // Start transaction - int64_t tx_timestamp; - Status status = ::procedure_start_mut_tx(&tx_timestamp); - if (is_error(status)) { - LOG_PANIC("Failed to start transaction"); - } - - // Create a ReducerContext for this transaction - // Note: connection_id converted to std::optional - ReducerContext reducer_ctx( - sender(), - std::optional(connection_id), - Timestamp::from_micros_since_epoch(tx_timestamp) - ); - - // Create transaction context wrapping the reducer context - TxContext tx{reducer_ctx}; - - // Execute callback - if constexpr (std::is_void_v) { - body(tx); - - // Commit transaction - status = ::procedure_commit_mut_tx(); - if (is_error(status)) { - LOG_PANIC("Failed to commit transaction"); - } - } else { - ResultType result = body(tx); - - // Commit transaction - status = ::procedure_commit_mut_tx(); - if (is_error(status)) { - LOG_PANIC("Failed to commit transaction"); - } - - return result; - } + auto make_reducer_ctx = [this](Timestamp tx_timestamp) { + return ReducerContext( + sender(), + std::optional(connection_id), + tx_timestamp + ); + }; + return Internal::with_tx(make_reducer_ctx, body); } /** @@ -260,51 +229,14 @@ struct ProcedureContext { */ template auto try_with_tx(Func&& body) -> decltype(body(std::declval())) { - using ResultType = decltype(body(std::declval())); - - // Start transaction - int64_t tx_timestamp; - Status status = ::procedure_start_mut_tx(&tx_timestamp); - if (is_error(status)) { - LOG_PANIC("Failed to start transaction"); - } - - // Create a ReducerContext for this transaction - ReducerContext reducer_ctx( - sender(), - std::optional(connection_id), - Timestamp::from_micros_since_epoch(tx_timestamp) - ); - - // Create transaction context wrapping the reducer context - TxContext tx{reducer_ctx}; - - // Execute callback - ResultType result = body(tx); - - // For bool results, use the value to decide commit/rollback - // For other types, always commit (caller can use LOG_PANIC to abort) - if constexpr (std::is_same_v) { - if (result) { - status = ::procedure_commit_mut_tx(); - if (is_error(status)) { - LOG_PANIC("Failed to commit transaction"); - } - } else { - status = ::procedure_abort_mut_tx(); - if (is_error(status)) { - LOG_PANIC("Failed to rollback transaction"); - } - } - } else { - // For non-bool returns, always commit - status = ::procedure_commit_mut_tx(); - if (is_error(status)) { - LOG_PANIC("Failed to commit transaction"); - } - } - - return result; + auto make_reducer_ctx = [this](Timestamp tx_timestamp) { + return ReducerContext( + sender(), + std::optional(connection_id), + tx_timestamp + ); + }; + return Internal::try_with_tx(make_reducer_ctx, body); } #endif }; diff --git a/crates/bindings-cpp/include/spacetimedb/reducer_context.h b/crates/bindings-cpp/include/spacetimedb/reducer_context.h index 8c8fba26e72..41865b14f3a 100644 --- a/crates/bindings-cpp/include/spacetimedb/reducer_context.h +++ b/crates/bindings-cpp/include/spacetimedb/reducer_context.h @@ -124,6 +124,9 @@ struct ReducerContext { ReducerContext(Identity s, std::optional cid, Timestamp ts) : sender_(s), connection_id(cid), timestamp(ts), sender_auth_(AuthCtx::from_connection_id_opt(cid, s)) {} + + ReducerContext(Identity s, std::optional cid, Timestamp ts, AuthCtx auth) + : sender_(s), connection_id(cid), timestamp(ts), sender_auth_(std::move(auth)) {} }; } // namespace SpacetimeDB diff --git a/crates/bindings-cpp/include/spacetimedb/router.h b/crates/bindings-cpp/include/spacetimedb/router.h new file mode 100644 index 00000000000..00808f8bdfd --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/router.h @@ -0,0 +1,237 @@ +#ifndef SPACETIMEDB_ROUTER_H +#define SPACETIMEDB_ROUTER_H + +#ifndef SPACETIMEDB_UNSTABLE_FEATURES +#error "spacetimedb/router.h requires SPACETIMEDB_UNSTABLE_FEATURES to be enabled" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace SpacetimeDB { + +class Router { +public: + struct RouteSpec { + Internal::MethodOrAny method; + std::string path; + std::string handler_name; + }; + + Router() = default; + + template + Router get(std::string path, Func handler) const { + return add_route(make_method(HttpMethod::get()), std::move(path), handler); + } + + template + Router head(std::string path, Func handler) const { + return add_route(make_method(HttpMethod::head()), std::move(path), handler); + } + + template + Router options(std::string path, Func handler) const { + return add_route(make_method(HttpMethod::options()), std::move(path), handler); + } + + template + Router put(std::string path, Func handler) const { + return add_route(make_method(HttpMethod::put()), std::move(path), handler); + } + + template + Router delete_(std::string path, Func handler) const { + return add_route(make_method(HttpMethod::del()), std::move(path), handler); + } + + template + Router post(std::string path, Func handler) const { + return add_route(make_method(HttpMethod::post()), std::move(path), handler); + } + + template + Router patch(std::string path, Func handler) const { + return add_route(make_method(HttpMethod::patch()), std::move(path), handler); + } + + template + Router any(std::string path, Func handler) const { + return add_route(make_any(), std::move(path), handler); + } + + Router nest(std::string path, const Router& sub_router) const { + assert_valid_path(path); + Router merged = *this; + for (const auto& route : routes_) { + if (route.path.starts_with(path)) { + fail_router_registration("Cannot nest router at `" + path + "`; existing routes overlap with nested path"); + } + } + for (const auto& route : sub_router.routes_) { + merged = merged.add_route(route.method, join_paths(path, route.path), route.handler_name); + } + return merged; + } + + Router merge(const Router& other) const { + Router merged = *this; + for (const auto& route : other.routes_) { + merged = merged.add_route(route.method, route.path, route.handler_name); + } + return merged; + } + + const std::vector& routes() const { + return routes_; + } + +private: + std::vector routes_; + + [[noreturn]] static void fail_router_registration(const std::string& message) { + std::fprintf(stderr, "Router registration failed: %s\n", message.c_str()); + std::abort(); + } + + static bool character_is_acceptable_for_route_path(char c) { + return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '~' || c == '/'; + } + + static void assert_valid_path(const std::string& path) { + if (!path.empty() && path[0] != '/') { + fail_router_registration("Route paths must start with `/`: " + path); + } + for (char c : path) { + if (!character_is_acceptable_for_route_path(c)) { + fail_router_registration("Route paths may contain only ASCII lowercase letters, digits and `-_~/`: " + path); + } + } + } + + static std::string join_paths(const std::string& prefix, const std::string& suffix) { + if (prefix == "/") { + return suffix; + } + if (suffix == "/") { + return prefix; + } + std::string trimmed_prefix = prefix; + while (!trimmed_prefix.empty() && trimmed_prefix.back() == '/') { + trimmed_prefix.pop_back(); + } + size_t start = 0; + while (start < suffix.size() && suffix[start] == '/') { + ++start; + } + return trimmed_prefix + "/" + suffix.substr(start); + } + + static bool routes_overlap(const RouteSpec& a, const RouteSpec& b) { + if (a.path != b.path) { + return false; + } + if (a.method.is<0>() || b.method.is<0>()) { + return true; + } + return method_key(a.method.template get<1>()) == method_key(b.method.template get<1>()); + } + + static std::string method_key(const Internal::HttpMethod& method) { + switch (method.get_tag()) { + case 0: + return "GET"; + case 1: + return "HEAD"; + case 2: + return "POST"; + case 3: + return "PUT"; + case 4: + return "DELETE"; + case 5: + return "CONNECT"; + case 6: + return "OPTIONS"; + case 7: + return "TRACE"; + case 8: + return "PATCH"; + case 9: + return method.template get<9>(); + default: + fail_router_registration("Unsupported internal HTTP method tag"); + } + } + + static Internal::MethodOrAny make_any() { + Internal::MethodOrAny method; + method.set<0>(std::monostate{}); + return method; + } + + static Internal::MethodOrAny make_method(const HttpMethod& method) { + Internal::MethodOrAny result; + result.set<1>(to_internal_http_method(method)); + return result; + } + + static Internal::HttpMethod to_internal_http_method(const HttpMethod& method) { + Internal::HttpMethod result; + if (method.value == "GET") { + result.set<0>(std::monostate{}); + } else if (method.value == "HEAD") { + result.set<1>(Internal::HttpMethod_Head_Wrapper{}); + } else if (method.value == "POST") { + result.set<2>(Internal::HttpMethod_Post_Wrapper{}); + } else if (method.value == "PUT") { + result.set<3>(Internal::HttpMethod_Put_Wrapper{}); + } else if (method.value == "DELETE") { + result.set<4>(Internal::HttpMethod_Delete_Wrapper{}); + } else if (method.value == "CONNECT") { + result.set<5>(Internal::HttpMethod_Connect_Wrapper{}); + } else if (method.value == "OPTIONS") { + result.set<6>(Internal::HttpMethod_Options_Wrapper{}); + } else if (method.value == "TRACE") { + result.set<7>(Internal::HttpMethod_Trace_Wrapper{}); + } else if (method.value == "PATCH") { + result.set<8>(Internal::HttpMethod_Patch_Wrapper{}); + } else { + result.set<9>(method.value); + } + return result; + } + + template + Router add_route(Internal::MethodOrAny method, std::string path, Func handler) const { + return add_route(std::move(method), std::move(path), resolve_handler_name(handler)); + } + + Router add_route(Internal::MethodOrAny method, std::string path, std::string handler_name) const { + assert_valid_path(path); + RouteSpec candidate{method, path, std::move(handler_name)}; + for (const auto& route : routes_) { + if (routes_overlap(route, candidate)) { + fail_router_registration("Route conflict for `" + candidate.path + "`"); + } + } + Router next = *this; + next.routes_.push_back(std::move(candidate)); + return next; + } + + template + static std::string resolve_handler_name(Func handler) { + return Internal::LookupHttpHandlerName(handler); + } +}; + +} // namespace SpacetimeDB + +#endif // SPACETIMEDB_ROUTER_H diff --git a/crates/bindings-cpp/src/abi/module_exports.cpp b/crates/bindings-cpp/src/abi/module_exports.cpp index 6156e32025a..a31d50be1fb 100644 --- a/crates/bindings-cpp/src/abi/module_exports.cpp +++ b/crates/bindings-cpp/src/abi/module_exports.cpp @@ -99,4 +99,23 @@ extern "C" { ); } + STDB_EXPORT(__call_http_handler__) + int16_t __call_http_handler__( + uint32_t id, + uint64_t timestamp_microseconds, + SpacetimeDB::BytesSource request_source, + SpacetimeDB::BytesSource request_body_source, + SpacetimeDB::BytesSink response_sink, + SpacetimeDB::BytesSink response_body_sink + ) { + return SpacetimeDB::Internal::Module::__call_http_handler__( + id, + timestamp_microseconds, + request_source, + request_body_source, + response_sink, + response_body_sink + ); + } + } // extern "C" diff --git a/crates/bindings-cpp/src/internal/Module.cpp b/crates/bindings-cpp/src/internal/Module.cpp index b0dcb1ceae3..901bb5e3e86 100644 --- a/crates/bindings-cpp/src/internal/Module.cpp +++ b/crates/bindings-cpp/src/internal/Module.cpp @@ -16,6 +16,11 @@ #include "spacetimedb/reducer_error.h" #include "spacetimedb/view_context.h" #include "spacetimedb/procedure_context.h" +#include "spacetimedb/handler_context.h" +#include "spacetimedb/http_convert.h" +#include "spacetimedb/http_wire.h" +#include +#include #include #include #include @@ -55,6 +60,13 @@ namespace Internal { std::function(ProcedureContext&, BytesSource)> handler; }; static std::vector g_procedure_handlers; + + struct HttpHandler { + std::string name; + HttpHandlerSymbol symbol; + std::function handler; + }; + static std::vector g_http_handlers; /** * @brief View result header for serializing view return values @@ -116,6 +128,23 @@ namespace Internal { std::function(ProcedureContext&, BytesSource)> handler) { g_procedure_handlers.push_back({name, handler}); } + + void RegisterHttpHandlerHandler(const std::string& name, + HttpHandlerSymbol handler_symbol, + std::function handler) { + g_http_handlers.push_back({name, handler_symbol, handler}); + } + + std::string LookupHttpHandlerName(HttpHandlerSymbol handler_symbol) { + auto it = std::find_if(g_http_handlers.begin(), g_http_handlers.end(), [&](const auto& existing) { + return existing.symbol == handler_symbol; + }); + if (it == g_http_handlers.end()) { + fprintf(stderr, "ERROR: HTTP handler must be registered before it is referenced by a router\n"); + std::abort(); + } + return it->name; + } // Get the number of registered view handlers size_t GetViewHandlerCount() { @@ -130,6 +159,10 @@ namespace Internal { size_t GetProcedureHandlerCount() { return g_procedure_handlers.size(); } + + size_t GetHttpHandlerCount() { + return g_http_handlers.size(); + } void SetTableIsEventFlag(const std::string& table_name, bool is_event) { getV10Builder().SetTableIsEventFlag(table_name, is_event); @@ -146,6 +179,7 @@ namespace Internal { g_view_handlers.clear(); // Clear view handlers g_view_anon_handlers.clear(); // Clear anonymous view handlers g_procedure_handlers.clear(); // Clear procedure handlers + g_http_handlers.clear(); // Clear http handlers g_multiple_primary_key_error = false; // Reset error flag g_multiple_primary_key_table_name = ""; // Reset error table name g_constraint_registration_error = false; @@ -630,6 +664,41 @@ int16_t Module::__call_procedure__( return 0; // Success (StatusCode::OK) } +int16_t Module::__call_http_handler__( + uint32_t id, + uint64_t timestamp_microseconds, + BytesSource request_source, + BytesSource request_body_source, + BytesSink response_sink, + BytesSink response_body_sink +) { + if (id >= g_http_handlers.size()) { + fprintf(stderr, "ERROR: Invalid http handler ID %u (have %zu handlers)\n", + id, g_http_handlers.size()); + return -1; + } + + Timestamp timestamp = Timestamp::from_micros_since_epoch(static_cast(timestamp_microseconds)); + HandlerContext ctx(timestamp); + + std::vector request_bytes = ConsumeBytes(request_source); + bsatn::Reader request_reader(request_bytes.data(), request_bytes.size()); + wire::HttpRequest wire_request = bsatn::deserialize(request_reader); + HttpRequest request = convert::from_wire(wire_request, ConsumeBytes(request_body_source)); + + HttpResponse response = g_http_handlers[id].handler(ctx, std::move(request)); + auto [wire_response, response_body] = convert::to_wire_split(response); + + std::vector response_metadata; + { + bsatn::Writer writer(response_metadata); + bsatn::serialize(writer, wire_response); + } + WriteBytes(response_sink, response_metadata); + WriteBytes(response_body_sink, response_body); + return 0; +} + void Module::SetCaseConversionPolicy(CaseConversionPolicy policy) { getV10Builder().SetCaseConversionPolicy(policy); } @@ -657,4 +726,3 @@ void Module::RegisterExplicitIndexName(const std::string& source_name, const std - diff --git a/crates/bindings-cpp/src/internal/v10_builder.cpp b/crates/bindings-cpp/src/internal/v10_builder.cpp index 65f1895b035..eb22114e8b9 100644 --- a/crates/bindings-cpp/src/internal/v10_builder.cpp +++ b/crates/bindings-cpp/src/internal/v10_builder.cpp @@ -7,6 +7,7 @@ #include "spacetimedb/internal/autogen/RawScopedTypeNameV10.g.h" #include "spacetimedb/internal/autogen/FunctionVisibility.g.h" #include "spacetimedb/internal/autogen/ExplicitNames.g.h" +#include "spacetimedb/router.h" #include #include @@ -37,6 +38,8 @@ void V10Builder::Clear() { reducers_.clear(); procedures_.clear(); views_.clear(); + http_handlers_.clear(); + http_routes_.clear(); schedules_.clear(); lifecycle_reducers_.clear(); row_level_security_.clear(); @@ -150,6 +153,31 @@ void V10Builder::UpsertView(const RawViewDefV10& view) { } } +void V10Builder::UpsertHttpHandler(const RawHttpHandlerDefV10& handler) { + auto it = std::find_if(http_handlers_.begin(), http_handlers_.end(), [&](const auto& existing) { + return existing.source_name == handler.source_name; + }); + if (it == http_handlers_.end()) { + http_handlers_.push_back(handler); + } else { + *it = handler; + } +} + +void V10Builder::RegisterHttpHandlerDef(const std::string& handler_name) { + UpsertHttpHandler(RawHttpHandlerDefV10{handler_name}); +} + +void V10Builder::RegisterHttpRoute(const RawHttpRouteDefV10& route) { + http_routes_.push_back(route); +} + +void V10Builder::RegisterHttpRouter(const ::SpacetimeDB::Router& router) { + for (const auto& route : router.routes()) { + RegisterHttpRoute(RawHttpRouteDefV10{route.handler_name, route.method, route.path}); + } +} + RawIndexDefV10 V10Builder::CreateBTreeIndex(const std::string& table_name, const std::string& source_name, const std::vector& columns, @@ -256,6 +284,16 @@ RawModuleDefV10 V10Builder::BuildModuleDef() const { section_explicit_names.set<10>(ExplicitNames{explicit_names_}); v10_module.sections.push_back(std::move(section_explicit_names)); } + if (!http_handlers_.empty()) { + RawModuleDefV10Section section_http_handlers; + section_http_handlers.set<11>(http_handlers_); + v10_module.sections.push_back(std::move(section_http_handlers)); + } + if (!http_routes_.empty()) { + RawModuleDefV10Section section_http_routes; + section_http_routes.set<12>(http_routes_); + v10_module.sections.push_back(std::move(section_http_routes)); + } if (!row_level_security_.empty()) { RawModuleDefV10Section section_rls; section_rls.set<8>(row_level_security_); diff --git a/crates/bindings-cpp/tests/compile/CMakeLists.module.txt b/crates/bindings-cpp/tests/compile/CMakeLists.module.txt new file mode 100644 index 00000000000..63d00912c90 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/CMakeLists.module.txt @@ -0,0 +1,49 @@ +cmake_minimum_required(VERSION 3.16) +project(module) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +if(NOT DEFINED MODULE_SOURCE) + message(FATAL_ERROR "MODULE_SOURCE must be defined") +endif() + +if(NOT DEFINED OUTPUT_NAME) + set(OUTPUT_NAME "module") +endif() + +if(NOT DEFINED SPACETIMEDB_LIBRARY_DIR) + message(FATAL_ERROR "SPACETIMEDB_LIBRARY_DIR must be defined") +endif() + +if(NOT DEFINED SPACETIMEDB_INCLUDE_DIR) + message(FATAL_ERROR "SPACETIMEDB_INCLUDE_DIR must be defined") +endif() + +add_executable(${OUTPUT_NAME} ${MODULE_SOURCE}) +target_include_directories(${OUTPUT_NAME} PRIVATE ${SPACETIMEDB_INCLUDE_DIR}) +target_link_directories(${OUTPUT_NAME} PRIVATE ${SPACETIMEDB_LIBRARY_DIR}) +target_link_libraries(${OUTPUT_NAME} PRIVATE spacetimedb_cpp_library) +target_compile_definitions(${OUTPUT_NAME} PRIVATE SPACETIMEDB_UNSTABLE_FEATURES) + +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + set(EXPORTED_FUNCS "['_malloc','_free','___describe_module__','___call_reducer__','___call_http_handler__']") + + target_link_options(${OUTPUT_NAME} PRIVATE + "SHELL:-sSTANDALONE_WASM=1" + "SHELL:-sWASM=1" + "SHELL:--no-entry" + "SHELL:-sEXPORTED_FUNCTIONS=${EXPORTED_FUNCS}" + "SHELL:-sERROR_ON_UNDEFINED_SYMBOLS=1" + "SHELL:-sFILESYSTEM=0" + "SHELL:-sDISABLE_EXCEPTION_CATCHING=1" + "SHELL:-sALLOW_MEMORY_GROWTH=0" + "SHELL:-sINITIAL_MEMORY=16MB" + "SHELL:-sSUPPORT_LONGJMP=0" + "SHELL:-sSUPPORT_ERRNO=0" + "SHELL:-std=c++20" + "SHELL:-O2" + ) + + set_target_properties(${OUTPUT_NAME} PROPERTIES OUTPUT_NAME "lib" SUFFIX ".wasm") +endif() diff --git a/crates/bindings-cpp/tests/compile/README.md b/crates/bindings-cpp/tests/compile/README.md new file mode 100644 index 00000000000..34c011b0931 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/README.md @@ -0,0 +1,64 @@ +# SpacetimeDB C++ Compile Tests + +Focused compile-surface regression tests for the C++ bindings. + +This harness is intended for: +- authoring-time success cases +- compile-fail regression cases +- API surface checks that should fail before publish/runtime + +## HTTP Handler Coverage + +The `http-handlers` suite mirrors the Rust coverage in +`crates/bindings/tests/ui/http_handlers.rs` as closely as the C++ macro surface allows. + +Covered cases: +- valid handler/router authoring +- no handler args +- immutable handler context +- wrong handler context type +- missing request arg +- wrong request arg type +- missing return +- wrong return type +- forbidden `HandlerContext::sender()` +- forbidden `HandlerContext::connection_id` +- forbidden `HandlerContext::db` +- router authored with args +- router wrong return type +- router misuse in a non-function position + +## Run + +From Git Bash or Linux-style shells: + +```bash +./crates/bindings-cpp/tests/compile/run-compile-tests.sh --suite http-handlers +``` + +From PowerShell at the repo root: + +```powershell +.\crates\bindings-cpp\tests\compile\run-compile-tests.ps1 -Suite http-handlers +``` + +Or from the compile test directory: + +```powershell +.\run-compile-tests.ps1 -Suite http-handlers +``` + +## Output + +Build artifacts and logs are written under: + +```text +crates/bindings-cpp/tests/compile/build/ +``` + +Each case gets: +- `build//configure.log` +- `build//build.log` + +The shared bindings library build is under: +- `build/library/` diff --git a/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_immutable_ctx.cpp b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_immutable_ctx.cpp new file mode 100644 index 00000000000..acb7d2f731a --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_immutable_ctx.cpp @@ -0,0 +1,12 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_immutable_ctx, const HandlerContext& ctx, HttpRequest request) { + return HttpResponse{ + 200, + HttpVersion::Http11, + {}, + HttpBody::from_string("ok"), + }; +} diff --git a/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_args.cpp b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_args.cpp new file mode 100644 index 00000000000..7391a55450f --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_args.cpp @@ -0,0 +1,12 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_no_args) { + return HttpResponse{ + 200, + HttpVersion::Http11, + {}, + HttpBody::from_string("ok"), + }; +} diff --git a/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_connection_id.cpp b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_connection_id.cpp new file mode 100644 index 00000000000..c775e6c3430 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_connection_id.cpp @@ -0,0 +1,13 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_no_connection_id, HandlerContext ctx, HttpRequest request) { + auto conn_id = ctx.connection_id(); + return HttpResponse{ + 200, + HttpVersion::Http11, + {}, + HttpBody::from_string(conn_id.to_hex_string()), + }; +} diff --git a/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_db.cpp b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_db.cpp new file mode 100644 index 00000000000..4f650b55e71 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_db.cpp @@ -0,0 +1,19 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +struct TestRow { + uint32_t value; +}; +SPACETIMEDB_STRUCT(TestRow, value) +SPACETIMEDB_TABLE(TestRow, test_row, Public) + +SPACETIMEDB_HTTP_HANDLER(handler_no_db, HandlerContext ctx, HttpRequest request) { + auto count = ctx.db[test_row].count(); + return HttpResponse{ + 200, + HttpVersion::Http11, + {}, + HttpBody::from_string(std::to_string(count)), + }; +} diff --git a/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_request_arg.cpp b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_request_arg.cpp new file mode 100644 index 00000000000..4543a97ef8c --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_request_arg.cpp @@ -0,0 +1,12 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_no_request_arg, HandlerContext ctx) { + return HttpResponse{ + 200, + HttpVersion::Http11, + {}, + HttpBody::from_string("ok"), + }; +} diff --git a/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_return_type.cpp b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_return_type.cpp new file mode 100644 index 00000000000..ea22473d38f --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_return_type.cpp @@ -0,0 +1,10 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +#if defined(__clang__) +#pragma clang diagnostic error "-Wreturn-type" +#endif + +SPACETIMEDB_HTTP_HANDLER(handler_no_return_type, HandlerContext ctx, HttpRequest request) { +} diff --git a/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_sender.cpp b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_sender.cpp new file mode 100644 index 00000000000..9f4ba51b5d0 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_sender.cpp @@ -0,0 +1,13 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_no_sender, HandlerContext ctx, HttpRequest request) { + auto sender = ctx.sender(); + return HttpResponse{ + 200, + HttpVersion::Http11, + {}, + HttpBody::from_string(sender.to_hex_string()), + }; +} diff --git a/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_ctx.cpp b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_ctx.cpp new file mode 100644 index 00000000000..01893c6d278 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_ctx.cpp @@ -0,0 +1,12 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_wrong_ctx, ProcedureContext ctx, HttpRequest request) { + return HttpResponse{ + 200, + HttpVersion::Http11, + {}, + HttpBody::from_string("ok"), + }; +} diff --git a/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_request_arg_type.cpp b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_request_arg_type.cpp new file mode 100644 index 00000000000..6ee189776b6 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_request_arg_type.cpp @@ -0,0 +1,12 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_wrong_request_arg_type, HandlerContext ctx, uint32_t request) { + return HttpResponse{ + 200, + HttpVersion::Http11, + {}, + HttpBody::from_string("ok"), + }; +} diff --git a/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_return_type.cpp b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_return_type.cpp new file mode 100644 index 00000000000..b04c6bfaa3e --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_return_type.cpp @@ -0,0 +1,7 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_wrong_return_type, HandlerContext ctx, HttpRequest request) { + return 7u; +} diff --git a/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_not_a_function.cpp b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_not_a_function.cpp new file mode 100644 index 00000000000..a18cb84cd0f --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_not_a_function.cpp @@ -0,0 +1,5 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_ROUTER(register_http_routes) = Router(); diff --git a/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_with_args.cpp b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_with_args.cpp new file mode 100644 index 00000000000..c4f7fb6a5d3 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_with_args.cpp @@ -0,0 +1,16 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(hello_handler, HandlerContext ctx, HttpRequest request) { + return HttpResponse{ + 200, + HttpVersion::Http11, + {}, + HttpBody::from_string("ok"), + }; +} + +SPACETIMEDB_HTTP_ROUTER(register_http_routes, HandlerContext ctx) { + return Router().get("/hello", hello_handler); +} diff --git a/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_wrong_return_type.cpp b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_wrong_return_type.cpp new file mode 100644 index 00000000000..fd20453bc4c --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_wrong_return_type.cpp @@ -0,0 +1,7 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_ROUTER(register_http_routes) { + return 7u; +} diff --git a/crates/bindings-cpp/tests/compile/cases/http-handlers/ok_http_handlers_basic.cpp b/crates/bindings-cpp/tests/compile/cases/http-handlers/ok_http_handlers_basic.cpp new file mode 100644 index 00000000000..e482c0561df --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/ok_http_handlers_basic.cpp @@ -0,0 +1,28 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(hello_handler, HandlerContext ctx, HttpRequest request) { + return HttpResponse{ + 200, + HttpVersion::Http11, + {}, + HttpBody::from_string("ok"), + }; +} + +SPACETIMEDB_HTTP_ROUTER(register_http_routes) { + Router nested = Router() + .get("/nested", hello_handler); + + Router merged = Router() + .get("", hello_handler) + .head("/health", hello_handler); + + return Router() + .get("/hello", hello_handler) + .delete_("/delete", hello_handler) + .any("/", hello_handler) + .merge(merged) + .nest("/api", nested); +} diff --git a/crates/bindings-cpp/tests/compile/run-compile-tests.ps1 b/crates/bindings-cpp/tests/compile/run-compile-tests.ps1 new file mode 100644 index 00000000000..2dd8a40eaa4 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/run-compile-tests.ps1 @@ -0,0 +1,221 @@ +[CmdletBinding()] +param( + [ValidateSet("http-handlers")] + [string]$Suite = "http-handlers" +) + +$ErrorActionPreference = "Stop" + +function Find-Emcmake { + $candidates = @( + (Get-Command emcmake.bat -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -First 1), + (Get-Command emcmake -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -First 1) + ) | Where-Object { $_ } + + if ($candidates.Count -eq 0) { + throw "Unable to locate emcmake or emcmake.bat." + } + + return $candidates[0] +} + +function Invoke-LoggedCommand { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + [Parameter(Mandatory = $true)] + [string]$LogPath, + [string]$WorkingDirectory + ) + + if ($WorkingDirectory) { + Push-Location $WorkingDirectory + } + + try { + $quotedParts = @($FilePath) + $Arguments | ForEach-Object { + '"' + ($_ -replace '"', '\"') + '"' + } + $commandLine = ($quotedParts -join ' ') + " > `"$LogPath`" 2>&1" + + cmd /c $commandLine | Out-Null + return $LASTEXITCODE + } finally { + if ($WorkingDirectory) { + Pop-Location + } + } +} + +function New-CompileCase { + param( + [string]$Name, + [string]$RelativePath, + [ValidateSet("success", "failure")] + [string]$Expectation, + [string]$Marker = "" + ) + + return [pscustomobject]@{ + Name = $Name + RelativePath = $RelativePath + Expectation = $Expectation + Marker = $Marker + } +} + +function Convert-ToCMakePath { + param([Parameter(Mandatory = $true)][string]$Path) + return $Path.Replace('\', '/') +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$bindingsRoot = Split-Path -Parent (Split-Path -Parent $scriptDir) +$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $bindingsRoot)) +$includeDir = Join-Path $bindingsRoot "include" +$buildRoot = Join-Path $scriptDir "build" +$libraryBuildDir = Join-Path $buildRoot "library" +$libraryLogDir = Join-Path $buildRoot "logs" +$templatePath = Join-Path $scriptDir "CMakeLists.module.txt" +$emcmake = Find-Emcmake + +$cases = switch ($Suite) { + "http-handlers" { + @( + (New-CompileCase "ok_http_handlers_basic" "cases/http-handlers/ok_http_handlers_basic.cpp" "success") + (New-CompileCase "error_http_handler_no_args" "cases/http-handlers/error_http_handler_no_args.cpp" "failure" "too few arguments provided to function-like macro invocation") + (New-CompileCase "error_http_handler_immutable_ctx" "cases/http-handlers/error_http_handler_immutable_ctx.cpp" "failure" "First parameter of HTTP handler must be HandlerContext") + (New-CompileCase "error_http_handler_wrong_ctx" "cases/http-handlers/error_http_handler_wrong_ctx.cpp" "failure" "First parameter of HTTP handler must be HandlerContext") + (New-CompileCase "error_http_handler_no_request_arg" "cases/http-handlers/error_http_handler_no_request_arg.cpp" "failure" "too few arguments provided to function-like macro invocation") + (New-CompileCase "error_http_handler_wrong_request_arg_type" "cases/http-handlers/error_http_handler_wrong_request_arg_type.cpp" "failure" "Second parameter of HTTP handler must be HttpRequest") + (New-CompileCase "error_http_handler_no_return_type" "cases/http-handlers/error_http_handler_no_return_type.cpp" "failure" "non-void function does not return a value") + (New-CompileCase "error_http_handler_wrong_return_type" "cases/http-handlers/error_http_handler_wrong_return_type.cpp" "failure" "no viable conversion from returned value of type 'unsigned int' to function return type 'SpacetimeDB::HttpResponse'") + (New-CompileCase "error_http_handler_no_sender" "cases/http-handlers/error_http_handler_no_sender.cpp" "failure" "no member named 'sender' in 'SpacetimeDB::HandlerContext'") + (New-CompileCase "error_http_handler_no_connection_id" "cases/http-handlers/error_http_handler_no_connection_id.cpp" "failure" "no member named 'connection_id' in 'SpacetimeDB::HandlerContext'") + (New-CompileCase "error_http_handler_no_db" "cases/http-handlers/error_http_handler_no_db.cpp" "failure" "no member named 'db' in 'SpacetimeDB::HandlerContext'") + (New-CompileCase "error_http_router_not_a_function" "cases/http-handlers/error_http_router_not_a_function.cpp" "failure" "illegal initializer") + (New-CompileCase "error_http_router_with_args" "cases/http-handlers/error_http_router_with_args.cpp" "failure" "too many arguments provided to function-like macro invocation") + (New-CompileCase "error_http_router_wrong_return_type" "cases/http-handlers/error_http_router_wrong_return_type.cpp" "failure" "no viable conversion from returned value of type 'unsigned int' to function return type 'SpacetimeDB::Router'") + ) + } +} + +New-Item -ItemType Directory -Force -Path $buildRoot | Out-Null +New-Item -ItemType Directory -Force -Path $libraryLogDir | Out-Null + +$libraryConfigureLog = Join-Path $libraryLogDir "library-configure.log" +$libraryBuildLog = Join-Path $libraryLogDir "library-build.log" + +Write-Host "Building bindings library..." +$configureExit = Invoke-LoggedCommand -FilePath $emcmake -Arguments @( + "cmake", + "-S", $bindingsRoot, + "-B", $libraryBuildDir +) -LogPath $libraryConfigureLog -WorkingDirectory $scriptDir + +if ($configureExit -ne 0) { + Write-Host "Library configure failed. See $libraryConfigureLog" + exit 1 +} + +$buildExit = Invoke-LoggedCommand -FilePath "cmake" -Arguments @( + "--build", $libraryBuildDir +) -LogPath $libraryBuildLog -WorkingDirectory $scriptDir + +if ($buildExit -ne 0) { + Write-Host "Library build failed. See $libraryBuildLog" + exit 1 +} + +$results = @() + +foreach ($case in $cases) { + $caseSource = Join-Path $scriptDir $case.RelativePath + $caseBuildDir = Join-Path $buildRoot $case.Name + $configureLog = Join-Path $caseBuildDir "configure.log" + $buildLog = Join-Path $caseBuildDir "build.log" + $caseSourceCMake = Convert-ToCMakePath $caseSource + $libraryBuildDirCMake = Convert-ToCMakePath $libraryBuildDir + $includeDirCMake = Convert-ToCMakePath $includeDir + + if (Test-Path $caseBuildDir) { + Remove-Item $caseBuildDir -Recurse -Force + } + + New-Item -ItemType Directory -Force -Path $caseBuildDir | Out-Null + Copy-Item $templatePath (Join-Path $caseBuildDir "CMakeLists.txt") + + Write-Host "Running $($case.Name)..." + $configureExit = Invoke-LoggedCommand -FilePath $emcmake -Arguments @( + "cmake", + "-S", $caseBuildDir, + "-B", $caseBuildDir, + "-DMODULE_SOURCE=$caseSourceCMake", + "-DOUTPUT_NAME=$($case.Name)", + "-DSPACETIMEDB_LIBRARY_DIR=$libraryBuildDirCMake", + "-DSPACETIMEDB_INCLUDE_DIR=$includeDirCMake" + ) -LogPath $configureLog -WorkingDirectory $scriptDir + + $buildExit = 0 + if ($configureExit -eq 0) { + $buildExit = Invoke-LoggedCommand -FilePath "cmake" -Arguments @( + "--build", $caseBuildDir + ) -LogPath $buildLog -WorkingDirectory $scriptDir + } + + $combinedLog = "" + if (Test-Path $configureLog) { + $combinedLog += Get-Content $configureLog -Raw + } + if (Test-Path $buildLog) { + $combinedLog += "`n" + $combinedLog += Get-Content $buildLog -Raw + } + + $passed = $false + $detail = "" + if ($case.Expectation -eq "success") { + $passed = ($configureExit -eq 0 -and $buildExit -eq 0) + if (-not $passed) { + $detail = "Expected build success." + } + } else { + $failedBuild = ($configureExit -ne 0 -or $buildExit -ne 0) + $matchedMarker = ($case.Marker -and $combinedLog.Contains($case.Marker)) + $passed = ($failedBuild -and $matchedMarker) + if (-not $passed) { + if (-not $failedBuild) { + $detail = "Expected build failure." + } else { + $detail = "Expected marker not found: $($case.Marker)" + } + } + } + + if (-not $passed -and -not $detail) { + $detail = (($combinedLog -split "`r?`n" | Where-Object { $_.Trim() }) | Select-Object -First 8) -join " " + } + + $results += [pscustomobject]@{ + Case = $case.Name + Expectation = $case.Expectation + Result = if ($passed) { "PASS" } else { "FAIL" } + Detail = $detail + } +} + +$results | Format-Table -AutoSize + +if ($results.Result -contains "FAIL") { + Write-Host "" + Write-Host "Failures:" + $results | Where-Object Result -eq "FAIL" | ForEach-Object { + Write-Host "- $($_.Case): $($_.Detail)" + } + exit 1 +} + +Write-Host "" +Write-Host "All compile tests passed." diff --git a/crates/bindings-cpp/tests/compile/run-compile-tests.sh b/crates/bindings-cpp/tests/compile/run-compile-tests.sh new file mode 100644 index 00000000000..d7c634bc9d7 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/run-compile-tests.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BINDINGS_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +INCLUDE_DIR="$BINDINGS_ROOT/include" +BUILD_ROOT="$SCRIPT_DIR/build" +LIBRARY_BUILD_DIR="$BUILD_ROOT/library" +LIBRARY_LOG_DIR="$BUILD_ROOT/logs" +TEMPLATE_PATH="$SCRIPT_DIR/CMakeLists.module.txt" + +SUITE="http-handlers" + +while [[ $# -gt 0 ]]; do + case "$1" in + --suite) + SUITE="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +if [[ "$SUITE" != "http-handlers" ]]; then + echo "Unsupported suite: $SUITE" >&2 + exit 1 +fi + +if command -v emcmake >/dev/null 2>&1; then + EMCMAKE_CMD="emcmake" +elif command -v emcmake.bat >/dev/null 2>&1; then + EMCMAKE_CMD="emcmake.bat" +else + echo "Unable to locate emcmake or emcmake.bat" >&2 + exit 1 +fi + +mkdir -p "$BUILD_ROOT" "$LIBRARY_LOG_DIR" + +LIBRARY_CONFIGURE_LOG="$LIBRARY_LOG_DIR/library-configure.log" +LIBRARY_BUILD_LOG="$LIBRARY_LOG_DIR/library-build.log" + +echo "Building bindings library..." +if ! "$EMCMAKE_CMD" cmake -S "$BINDINGS_ROOT" -B "$LIBRARY_BUILD_DIR" >"$LIBRARY_CONFIGURE_LOG" 2>&1; then + echo "Library configure failed. See $LIBRARY_CONFIGURE_LOG" >&2 + exit 1 +fi + +if ! cmake --build "$LIBRARY_BUILD_DIR" >"$LIBRARY_BUILD_LOG" 2>&1; then + echo "Library build failed. See $LIBRARY_BUILD_LOG" >&2 + exit 1 +fi + +declare -a CASE_NAMES=( + "ok_http_handlers_basic" + "error_http_handler_no_args" + "error_http_handler_immutable_ctx" + "error_http_handler_wrong_ctx" + "error_http_handler_no_request_arg" + "error_http_handler_wrong_request_arg_type" + "error_http_handler_no_return_type" + "error_http_handler_wrong_return_type" + "error_http_handler_no_sender" + "error_http_handler_no_connection_id" + "error_http_handler_no_db" + "error_http_router_not_a_function" + "error_http_router_with_args" + "error_http_router_wrong_return_type" +) + +declare -A CASE_EXPECTATION +declare -A CASE_MARKER +declare -A CASE_SOURCE + +CASE_EXPECTATION["ok_http_handlers_basic"]="success" +CASE_SOURCE["ok_http_handlers_basic"]="$SCRIPT_DIR/cases/http-handlers/ok_http_handlers_basic.cpp" + +CASE_EXPECTATION["error_http_handler_no_args"]="failure" +CASE_MARKER["error_http_handler_no_args"]="too few arguments provided to function-like macro invocation" +CASE_SOURCE["error_http_handler_no_args"]="$SCRIPT_DIR/cases/http-handlers/error_http_handler_no_args.cpp" + +CASE_EXPECTATION["error_http_handler_immutable_ctx"]="failure" +CASE_MARKER["error_http_handler_immutable_ctx"]="First parameter of HTTP handler must be HandlerContext" +CASE_SOURCE["error_http_handler_immutable_ctx"]="$SCRIPT_DIR/cases/http-handlers/error_http_handler_immutable_ctx.cpp" + +CASE_EXPECTATION["error_http_handler_wrong_ctx"]="failure" +CASE_MARKER["error_http_handler_wrong_ctx"]="First parameter of HTTP handler must be HandlerContext" +CASE_SOURCE["error_http_handler_wrong_ctx"]="$SCRIPT_DIR/cases/http-handlers/error_http_handler_wrong_ctx.cpp" + +CASE_EXPECTATION["error_http_handler_no_request_arg"]="failure" +CASE_MARKER["error_http_handler_no_request_arg"]="too few arguments provided to function-like macro invocation" +CASE_SOURCE["error_http_handler_no_request_arg"]="$SCRIPT_DIR/cases/http-handlers/error_http_handler_no_request_arg.cpp" + +CASE_EXPECTATION["error_http_handler_wrong_request_arg_type"]="failure" +CASE_MARKER["error_http_handler_wrong_request_arg_type"]="Second parameter of HTTP handler must be HttpRequest" +CASE_SOURCE["error_http_handler_wrong_request_arg_type"]="$SCRIPT_DIR/cases/http-handlers/error_http_handler_wrong_request_arg_type.cpp" + +CASE_EXPECTATION["error_http_handler_no_return_type"]="failure" +CASE_MARKER["error_http_handler_no_return_type"]="non-void function does not return a value" +CASE_SOURCE["error_http_handler_no_return_type"]="$SCRIPT_DIR/cases/http-handlers/error_http_handler_no_return_type.cpp" + +CASE_EXPECTATION["error_http_handler_wrong_return_type"]="failure" +CASE_MARKER["error_http_handler_wrong_return_type"]="no viable conversion from returned value of type 'unsigned int' to function return type 'SpacetimeDB::HttpResponse'" +CASE_SOURCE["error_http_handler_wrong_return_type"]="$SCRIPT_DIR/cases/http-handlers/error_http_handler_wrong_return_type.cpp" + +CASE_EXPECTATION["error_http_handler_no_sender"]="failure" +CASE_MARKER["error_http_handler_no_sender"]="no member named 'sender' in 'SpacetimeDB::HandlerContext'" +CASE_SOURCE["error_http_handler_no_sender"]="$SCRIPT_DIR/cases/http-handlers/error_http_handler_no_sender.cpp" + +CASE_EXPECTATION["error_http_handler_no_connection_id"]="failure" +CASE_MARKER["error_http_handler_no_connection_id"]="no member named 'connection_id' in 'SpacetimeDB::HandlerContext'" +CASE_SOURCE["error_http_handler_no_connection_id"]="$SCRIPT_DIR/cases/http-handlers/error_http_handler_no_connection_id.cpp" + +CASE_EXPECTATION["error_http_handler_no_db"]="failure" +CASE_MARKER["error_http_handler_no_db"]="no member named 'db' in 'SpacetimeDB::HandlerContext'" +CASE_SOURCE["error_http_handler_no_db"]="$SCRIPT_DIR/cases/http-handlers/error_http_handler_no_db.cpp" + +CASE_EXPECTATION["error_http_router_not_a_function"]="failure" +CASE_MARKER["error_http_router_not_a_function"]="illegal initializer" +CASE_SOURCE["error_http_router_not_a_function"]="$SCRIPT_DIR/cases/http-handlers/error_http_router_not_a_function.cpp" + +CASE_EXPECTATION["error_http_router_with_args"]="failure" +CASE_MARKER["error_http_router_with_args"]="too many arguments provided to function-like macro invocation" +CASE_SOURCE["error_http_router_with_args"]="$SCRIPT_DIR/cases/http-handlers/error_http_router_with_args.cpp" + +CASE_EXPECTATION["error_http_router_wrong_return_type"]="failure" +CASE_MARKER["error_http_router_wrong_return_type"]="no viable conversion from returned value of type 'unsigned int' to function return type 'SpacetimeDB::Router'" +CASE_SOURCE["error_http_router_wrong_return_type"]="$SCRIPT_DIR/cases/http-handlers/error_http_router_wrong_return_type.cpp" + +FAILURES=0 + +for CASE_NAME in "${CASE_NAMES[@]}"; do + CASE_BUILD_DIR="$BUILD_ROOT/$CASE_NAME" + CONFIGURE_LOG="$CASE_BUILD_DIR/configure.log" + BUILD_LOG="$CASE_BUILD_DIR/build.log" + + rm -rf "$CASE_BUILD_DIR" + mkdir -p "$CASE_BUILD_DIR" + cp "$TEMPLATE_PATH" "$CASE_BUILD_DIR/CMakeLists.txt" + + echo "Running $CASE_NAME..." + + CONFIGURE_EXIT=0 + BUILD_EXIT=0 + + if "$EMCMAKE_CMD" cmake -S "$CASE_BUILD_DIR" -B "$CASE_BUILD_DIR" \ + -DMODULE_SOURCE="${CASE_SOURCE[$CASE_NAME]}" \ + -DOUTPUT_NAME="$CASE_NAME" \ + -DSPACETIMEDB_LIBRARY_DIR="$LIBRARY_BUILD_DIR" \ + -DSPACETIMEDB_INCLUDE_DIR="$INCLUDE_DIR" >"$CONFIGURE_LOG" 2>&1; then + CONFIGURE_EXIT=0 + else + CONFIGURE_EXIT=$? + fi + + if [[ $CONFIGURE_EXIT -eq 0 ]]; then + if cmake --build "$CASE_BUILD_DIR" >"$BUILD_LOG" 2>&1; then + BUILD_EXIT=0 + else + BUILD_EXIT=$? + fi + fi + + COMBINED_LOG="" + [[ -f "$CONFIGURE_LOG" ]] && COMBINED_LOG+="$(cat "$CONFIGURE_LOG")"$'\n' + [[ -f "$BUILD_LOG" ]] && COMBINED_LOG+="$(cat "$BUILD_LOG")" + + PASS=0 + DETAIL="" + + if [[ "${CASE_EXPECTATION[$CASE_NAME]}" == "success" ]]; then + if [[ $CONFIGURE_EXIT -eq 0 && $BUILD_EXIT -eq 0 ]]; then + PASS=1 + else + DETAIL="Expected build success." + fi + else + if [[ $CONFIGURE_EXIT -ne 0 || $BUILD_EXIT -ne 0 ]]; then + if [[ "$COMBINED_LOG" == *"${CASE_MARKER[$CASE_NAME]}"* ]]; then + PASS=1 + else + DETAIL="Expected marker not found: ${CASE_MARKER[$CASE_NAME]}" + fi + else + DETAIL="Expected build failure." + fi + fi + + if [[ $PASS -eq 1 ]]; then + printf '%-40s PASS\n' "$CASE_NAME" + else + printf '%-40s FAIL\n' "$CASE_NAME" + [[ -z "$DETAIL" ]] && DETAIL="$(printf '%s' "$COMBINED_LOG" | grep -v '^[[:space:]]*$' | head -n 8 | tr '\n' ' ')" + echo " $DETAIL" + FAILURES=1 + fi +done + +if [[ $FAILURES -ne 0 ]]; then + echo + echo "Compile test failures detected." + exit 1 +fi + +echo +echo "All compile tests passed." diff --git a/crates/bindings-cpp/tests/type-isolation-test/CMakeLists.module.txt b/crates/bindings-cpp/tests/type-isolation-test/CMakeLists.module.txt index 243fcc5b902..38db22d8b82 100644 --- a/crates/bindings-cpp/tests/type-isolation-test/CMakeLists.module.txt +++ b/crates/bindings-cpp/tests/type-isolation-test/CMakeLists.module.txt @@ -28,6 +28,7 @@ add_executable(${OUTPUT_NAME} ${MODULE_SOURCE}) # Include directories target_include_directories(${OUTPUT_NAME} PRIVATE ${SPACETIMEDB_INCLUDE_DIR}) +target_compile_definitions(${OUTPUT_NAME} PRIVATE SPACETIMEDB_UNSTABLE_FEATURES) # Link the pre-built library target_link_directories(${OUTPUT_NAME} PRIVATE ${SPACETIMEDB_LIBRARY_DIR}) @@ -61,4 +62,4 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") # Name the output lib.wasm set_target_properties(${OUTPUT_NAME} PROPERTIES OUTPUT_NAME "lib" SUFFIX ".wasm") -endif() \ No newline at end of file +endif() diff --git a/crates/bindings-cpp/tests/unit/CMakeLists.txt b/crates/bindings-cpp/tests/unit/CMakeLists.txt new file mode 100644 index 00000000000..0ced4e0194c --- /dev/null +++ b/crates/bindings-cpp/tests/unit/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.16) +project(bindings_cpp_unit_tests LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + message(FATAL_ERROR "tests/unit is intended to be built with Emscripten via emcmake") +endif() + +add_executable(bindings_cpp_unit_tests + main.cpp + http_unit_tests.cpp +) + +target_include_directories(bindings_cpp_unit_tests PRIVATE + ../../include +) + +target_compile_definitions(bindings_cpp_unit_tests PRIVATE + SPACETIMEDB_UNSTABLE_FEATURES +) + +if(MSVC) + target_compile_options(bindings_cpp_unit_tests PRIVATE /W4) +else() + target_compile_options(bindings_cpp_unit_tests PRIVATE -Wall -Wextra) +endif() + +target_link_options(bindings_cpp_unit_tests PRIVATE + "SHELL:-sWASM=1" + "SHELL:-sENVIRONMENT=node" + "SHELL:-sEXIT_RUNTIME=1" + "SHELL:-sASSERTIONS=1" + "SHELL:-O2" +) + +set_target_properties(bindings_cpp_unit_tests PROPERTIES SUFFIX ".cjs") diff --git a/crates/bindings-cpp/tests/unit/README.md b/crates/bindings-cpp/tests/unit/README.md new file mode 100644 index 00000000000..558027cb113 --- /dev/null +++ b/crates/bindings-cpp/tests/unit/README.md @@ -0,0 +1,52 @@ +# C++ Unit Tests + +Standalone unit-test harness for pure bindings/library behavior. + +This suite is the right home for: +- conversion helpers +- small pure-library regressions +- behavior that does not need wasm module compilation +- behavior that does not need a live SpacetimeDB server + +Current coverage includes the HTTP request/response split-body conversion checks that +mirror the Rust tests added next to `crates/bindings/src/http.rs`. + +This harness is intentionally separate from the top-level bindings CMake so that +small header-only/library tests do not need to build the full module ABI/export layer. + +It is built with Emscripten and run under Node, which matches the existing wasm-oriented +C++ test toolchain more closely than adding a separate native-MSVC path. + +The generated Node launcher uses a `.cjs` suffix so it is treated as CommonJS even though +the repo root sets `"type": "module"`. + +## Run + +Prerequisites: + +- `emcmake` on `PATH` +- `node` on `PATH` + +From PowerShell: + +```powershell +.\crates\bindings-cpp\tests\unit\run-unit-tests.ps1 +``` + +Verbose: + +```powershell +.\crates\bindings-cpp\tests\unit\run-unit-tests.ps1 -Detailed +``` + +From Git Bash: + +```bash +./crates/bindings-cpp/tests/unit/run-unit-tests.sh +``` + +Verbose: + +```bash +./crates/bindings-cpp/tests/unit/run-unit-tests.sh --verbose +``` diff --git a/crates/bindings-cpp/tests/unit/http_unit_tests.cpp b/crates/bindings-cpp/tests/unit/http_unit_tests.cpp new file mode 100644 index 00000000000..16d98db8f09 --- /dev/null +++ b/crates/bindings-cpp/tests/unit/http_unit_tests.cpp @@ -0,0 +1,56 @@ +#include "test_harness.h" + +#include "spacetimedb/http_convert.h" + +#include +#include +#include + +using namespace SpacetimeDB; + +TEST_CASE(request_from_wire_preserves_metadata_and_body) { + wire::HttpRequest request; + request.method = wire::HttpMethod{wire::HttpMethod::Tag::Post, ""}; + request.headers.entries = { + wire::HttpHeaderPair{"content-type", std::vector{'a','p','p','l','i','c','a','t','i','o','n','/','o','c','t','e','t','-','s','t','r','e','a','m'}}, + wire::HttpHeaderPair{"x-echo", std::vector{'v','a','l','u','e'}}, + }; + request.timeout = std::nullopt; + request.uri = "https://example.invalid/upload?x=1"; + request.version = wire::HttpVersion{wire::HttpVersion::Tag::Http2}; + + HttpRequest converted = convert::from_wire(request, std::vector{'p','a','y','l','o','a','d'}); + + ASSERT_EQ(std::string("POST"), converted.method.value); + ASSERT_EQ(std::string("https://example.invalid/upload?x=1"), converted.uri); + ASSERT_EQ(HttpVersion::Http2, converted.version); + ASSERT_EQ(static_cast(2), converted.headers.size()); + ASSERT_EQ(std::string("content-type"), converted.headers[0].name); + ASSERT_EQ(std::vector({'a','p','p','l','i','c','a','t','i','o','n','/','o','c','t','e','t','-','s','t','r','e','a','m'}), converted.headers[0].value); + ASSERT_EQ(std::string("x-echo"), converted.headers[1].name); + ASSERT_EQ(std::vector({'v','a','l','u','e'}), converted.headers[1].value); + ASSERT_EQ(std::vector({'p','a','y','l','o','a','d'}), converted.body.bytes); +} + +TEST_CASE(response_into_wire_splits_metadata_and_body) { + HttpResponse response{ + 201, + HttpVersion::Http11, + { + HttpHeader{"content-type", "text/plain"}, + HttpHeader{"x-result", "ok"}, + }, + HttpBody::from_string("created"), + }; + + auto [response_meta, response_body] = convert::to_wire_split(response); + + ASSERT_EQ(static_cast(201), response_meta.code); + ASSERT_EQ(wire::HttpVersion::Tag::Http11, response_meta.version.tag); + ASSERT_EQ(static_cast(2), response_meta.headers.entries.size()); + ASSERT_EQ(std::string("content-type"), response_meta.headers.entries[0].name); + ASSERT_EQ(std::vector({'t','e','x','t','/','p','l','a','i','n'}), response_meta.headers.entries[0].value); + ASSERT_EQ(std::string("x-result"), response_meta.headers.entries[1].name); + ASSERT_EQ(std::vector({'o','k'}), response_meta.headers.entries[1].value); + ASSERT_EQ(std::vector({'c','r','e','a','t','e','d'}), response_body); +} diff --git a/crates/bindings-cpp/tests/unit/main.cpp b/crates/bindings-cpp/tests/unit/main.cpp new file mode 100644 index 00000000000..054370390b6 --- /dev/null +++ b/crates/bindings-cpp/tests/unit/main.cpp @@ -0,0 +1,34 @@ +#include "test_harness.h" + +#include +#include + +int main(int argc, char** argv) { + bool verbose = argc > 1 && std::string(argv[1]) == "-v"; + int failures = 0; + + for (const auto& test : SpacetimeDB::UnitTests::all_tests()) { + try { + test.func(); + if (verbose) { + std::cout << "[PASS] " << test.name << '\n'; + } + } catch (const std::exception& ex) { + ++failures; + std::cerr << "[FAIL] " << test.name << ": " << ex.what() << '\n'; + } catch (...) { + ++failures; + std::cerr << "[FAIL] " << test.name << ": unknown exception\n"; + } + } + + if (!verbose) { + if (failures == 0) { + std::cout << "Passed " << SpacetimeDB::UnitTests::all_tests().size() << " unit tests\n"; + } else { + std::cerr << failures << " unit test(s) failed\n"; + } + } + + return failures == 0 ? 0 : 1; +} diff --git a/crates/bindings-cpp/tests/unit/run-unit-tests.ps1 b/crates/bindings-cpp/tests/unit/run-unit-tests.ps1 new file mode 100644 index 00000000000..39e023828d9 --- /dev/null +++ b/crates/bindings-cpp/tests/unit/run-unit-tests.ps1 @@ -0,0 +1,52 @@ +[CmdletBinding()] +param( + [switch]$Detailed +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$buildDir = Join-Path $scriptDir 'build' +$launcherPath = Join-Path $buildDir 'bindings_cpp_unit_tests.cjs' + +$emcmake = Get-Command emcmake.bat -ErrorAction SilentlyContinue +if ($null -eq $emcmake) { + $emcmake = Get-Command emcmake -ErrorAction SilentlyContinue +} +if ($null -eq $emcmake) { + throw 'Unable to locate emcmake or emcmake.bat' +} + +$node = Get-Command node -ErrorAction SilentlyContinue +if ($null -eq $node) { + throw 'Unable to locate node' +} + +Write-Host '' +Write-Host '==> Configuring unit tests' -ForegroundColor Cyan +& $emcmake.Source cmake -S $scriptDir -B $buildDir +if ($LASTEXITCODE -ne 0) { + throw "cmake configure failed with exit code $LASTEXITCODE" +} + +Write-Host '' +Write-Host '==> Building unit tests' -ForegroundColor Cyan +cmake --build $buildDir --target bindings_cpp_unit_tests +if ($LASTEXITCODE -ne 0) { + throw "cmake build failed with exit code $LASTEXITCODE" +} + +Write-Host '' +Write-Host '==> Running unit tests' -ForegroundColor Cyan +if (-not (Test-Path $launcherPath)) { + throw "Could not find built bindings_cpp_unit_tests.cjs launcher at $launcherPath" +} +if ($Detailed) { + & $node.Source $launcherPath -v +} else { + & $node.Source $launcherPath +} +if ($LASTEXITCODE -ne 0) { + throw "unit tests failed with exit code $LASTEXITCODE" +} diff --git a/crates/bindings-cpp/tests/unit/run-unit-tests.sh b/crates/bindings-cpp/tests/unit/run-unit-tests.sh new file mode 100644 index 00000000000..7de9aba68b8 --- /dev/null +++ b/crates/bindings-cpp/tests/unit/run-unit-tests.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" +VERBOSE=0 + +if command -v emcmake >/dev/null 2>&1; then + EMCMAKE_CMD="emcmake" +elif command -v emcmake.bat >/dev/null 2>&1; then + EMCMAKE_CMD="emcmake.bat" +else + echo "Unable to locate emcmake or emcmake.bat" >&2 + exit 1 +fi + +if ! command -v node >/dev/null 2>&1; then + echo "Unable to locate node" >&2 + exit 1 +fi + +while [[ $# -gt 0 ]]; do + case "$1" in + -v|--verbose) + VERBOSE=1 + shift + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +echo +echo "==> Configuring unit tests" +"$EMCMAKE_CMD" cmake -S "$SCRIPT_DIR" -B "$BUILD_DIR" + +echo +echo "==> Building unit tests" +cmake --build "$BUILD_DIR" --target bindings_cpp_unit_tests + +echo +echo "==> Running unit tests" +LAUNCHER="$BUILD_DIR/bindings_cpp_unit_tests.cjs" +if [[ ! -f "$LAUNCHER" ]]; then + echo "Could not find built bindings_cpp_unit_tests.cjs launcher" >&2 + exit 1 +fi + +if [[ $VERBOSE -eq 1 ]]; then + node "$LAUNCHER" -v +else + node "$LAUNCHER" +fi diff --git a/crates/bindings-cpp/tests/unit/test_harness.h b/crates/bindings-cpp/tests/unit/test_harness.h new file mode 100644 index 00000000000..c3e6dff4f64 --- /dev/null +++ b/crates/bindings-cpp/tests/unit/test_harness.h @@ -0,0 +1,49 @@ +#ifndef SPACETIMEDB_TEST_HARNESS_H +#define SPACETIMEDB_TEST_HARNESS_H + +#include +#include +#include + +namespace SpacetimeDB::UnitTests { + +struct TestCase { + const char* name; + void (*func)(); +}; + +inline std::vector& all_tests() { + static std::vector tests; + return tests; +} + +struct TestRegistrar { + TestRegistrar(const char* name, void (*func)()) { + all_tests().push_back(TestCase{name, func}); + } +}; + +} // namespace SpacetimeDB::UnitTests + +#define TEST_CASE(name) \ + void name(); \ + static ::SpacetimeDB::UnitTests::TestRegistrar name##_registrar(#name, &name); \ + void name() + +#define ASSERT_TRUE(condition) \ + do { \ + if (!(condition)) { \ + throw std::runtime_error(std::string("Assertion failed: ") + #condition); \ + } \ + } while (0) + +#define ASSERT_EQ(expected, actual) \ + do { \ + auto expected_value = (expected); \ + auto actual_value = (actual); \ + if (!(expected_value == actual_value)) { \ + throw std::runtime_error(std::string("Assertion failed: ") + #expected " == " #actual); \ + } \ + } while (0) + +#endif // SPACETIMEDB_TEST_HARNESS_H diff --git a/crates/codegen/src/cpp.rs b/crates/codegen/src/cpp.rs index 3f20b4d271a..9ddbeae8bb0 100644 --- a/crates/codegen/src/cpp.rs +++ b/crates/codegen/src/cpp.rs @@ -19,6 +19,37 @@ pub struct Cpp<'opts> { } impl<'opts> Cpp<'opts> { + fn cpp_field_name<'a>(&self, field_name: &'a str) -> &'a str { + match field_name { + "alignas" | "alignof" | "and" | "and_eq" | "asm" | "atomic_cancel" | "atomic_commit" + | "atomic_noexcept" | "auto" | "bitand" | "bitor" | "bool" | "break" | "case" | "catch" | "char" + | "char8_t" | "char16_t" | "char32_t" | "class" | "compl" | "concept" | "const" | "consteval" + | "constexpr" | "constinit" | "const_cast" | "continue" | "co_await" | "co_return" | "co_yield" + | "decltype" | "default" | "delete" | "do" | "double" | "dynamic_cast" | "else" | "enum" | "explicit" + | "export" | "extern" | "false" | "float" | "for" | "friend" | "goto" | "if" | "inline" | "int" + | "long" | "mutable" | "namespace" | "new" | "noexcept" | "not" | "not_eq" | "nullptr" | "operator" + | "or" | "or_eq" | "private" | "protected" | "public" | "register" | "reinterpret_cast" | "requires" + | "return" | "short" | "signed" | "sizeof" | "static" | "static_assert" | "static_cast" | "struct" + | "switch" | "template" | "this" | "thread_local" | "throw" | "true" | "try" | "typedef" | "typeid" + | "typename" | "union" | "unsigned" | "using" | "virtual" | "void" | "volatile" | "wchar_t" | "while" + | "xor" | "xor_eq" => "", + _ => field_name, + } + } + + fn write_cpp_field_name(&self, output: &mut String, field_name: &str) -> fmt::Result { + let escaped = self.cpp_field_name(field_name); + if escaped.is_empty() { + write!(output, "{}_", field_name) + } else { + write!(output, "{}", escaped) + } + } + + fn is_recursive_mount_module_field(&self, type_name: &str, field_name: &str) -> bool { + type_name == "RawModuleMountV10" && field_name == "module" + } + fn write_header_comment(&self, output: &mut String) { writeln!( output, @@ -148,8 +179,16 @@ impl<'opts> Cpp<'opts> { // Write fields only for (field_name, field_type) in &product.elements { write!(output, " ").unwrap(); - self.write_algebraic_type(output, module, field_type).unwrap(); - writeln!(output, " {};", field_name).unwrap(); + if self.is_recursive_mount_module_field(type_name, field_name) { + // Temporary special-case to preserve the recursive RawModuleMountV10 -> + // RawModuleDefV10 shape while breaking the include cycle in generated C++. + write!(output, "std::shared_ptr<{}::RawModuleDefV10>", self.namespace).unwrap(); + } else { + self.write_algebraic_type(output, module, field_type).unwrap(); + } + write!(output, " ").unwrap(); + self.write_cpp_field_name(output, field_name).unwrap(); + writeln!(output, ";").unwrap(); } writeln!(output).unwrap(); @@ -161,23 +200,30 @@ impl<'opts> Cpp<'opts> { ) .unwrap(); for (field_name, _) in &product.elements { - writeln!( - output, - " ::SpacetimeDB::bsatn::serialize(writer, {});", - field_name - ) - .unwrap(); + if self.is_recursive_mount_module_field(type_name, field_name) { + write!(output, " ::SpacetimeDB::bsatn::serialize(writer, *").unwrap(); + self.write_cpp_field_name(output, field_name).unwrap(); + writeln!(output, ");").unwrap(); + } else { + write!(output, " ::SpacetimeDB::bsatn::serialize(writer, ").unwrap(); + self.write_cpp_field_name(output, field_name).unwrap(); + writeln!(output, ");").unwrap(); + } } writeln!(output, " }}").unwrap(); // Generate equality method - if !product.elements.is_empty() { + if type_name == "RawModuleMountV10" { + // Pointer equality is sufficient for this internal autogen type. Mounts are not + // emitted by the C++ module path yet; this exists to keep the schema shape aligned. + writeln!(output, " SPACETIMEDB_PRODUCT_TYPE_EQUALITY(namespace_, module)").unwrap(); + } else if !product.elements.is_empty() { write!(output, " SPACETIMEDB_PRODUCT_TYPE_EQUALITY(").unwrap(); for (i, (field_name, _)) in product.elements.iter().enumerate() { if i > 0 { write!(output, ", ").unwrap(); } - write!(output, "{}", field_name).unwrap(); + self.write_cpp_field_name(output, field_name).unwrap(); } writeln!(output, ")").unwrap(); } @@ -493,13 +539,20 @@ impl Lang for Cpp<'_> { None => HashSet::new(), }; + let type_name = name.to_string(); for dep in deps { - if dep != name.to_string() { + if dep != type_name && !(type_name == "RawModuleMountV10" && dep == "RawModuleDefV10") { writeln!(output, "#include \"{}.g.h\"", dep).unwrap(); } } writeln!(output).unwrap(); + if type_name == "RawModuleMountV10" { + writeln!(output, "namespace {} {{", self.namespace).unwrap(); + writeln!(output, "struct RawModuleDefV10;").unwrap(); + writeln!(output, "}} // namespace {}", self.namespace).unwrap(); + writeln!(output).unwrap(); + } writeln!(output, "namespace {} {{", self.namespace).unwrap(); writeln!(output).unwrap(); diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 55f75b3ed73..ad3f0f0ca8f 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -386,6 +386,63 @@ pub fn have_emscripten() -> bool { *HAVE_EMSCRIPTEN.get_or_init(|| which("emcc").is_ok() || which("emcc.bat").is_ok()) } +const CPP_SMOKETEST_CMAKELISTS: &str = r#"cmake_minimum_required(VERSION 3.16) +project(smoketest_cpp_module) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(SPACETIMEDB_CPP_LIBRARY_PATH "@SPACETIMEDB_CPP_LIBRARY_PATH@") + +add_executable(lib src/lib.cpp) + +target_include_directories(lib PRIVATE + ${SPACETIMEDB_CPP_LIBRARY_PATH}/include +) + +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + target_compile_options(lib PRIVATE -fno-exceptions -O2 -g0) + target_compile_definitions(lib PRIVATE SPACETIMEDB_UNSTABLE_FEATURES) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -DSPACETIMEDB_UNSTABLE_FEATURES") +endif() + +add_subdirectory(${SPACETIMEDB_CPP_LIBRARY_PATH} ${CMAKE_CURRENT_BINARY_DIR}/spacetimedb_cpp_library) +target_link_libraries(lib PRIVATE spacetimedb_cpp_library) + +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + set(EXPORTED_FUNCS + "['_malloc','_free','___describe_module__','___call_reducer__','___call_procedure__','___call_http_handler__']" + ) + + target_link_options(lib PRIVATE + "SHELL:-sSTANDALONE_WASM=1" + "SHELL:-sWASM=1" + "SHELL:--no-entry" + "SHELL:-sEXPORTED_FUNCTIONS=${EXPORTED_FUNCS}" + "SHELL:-sERROR_ON_UNDEFINED_SYMBOLS=1" + "SHELL:-sFILESYSTEM=0" + "SHELL:-sDISABLE_EXCEPTION_CATCHING=1" + "SHELL:-sALLOW_MEMORY_GROWTH=0" + "SHELL:-sINITIAL_MEMORY=16MB" + "SHELL:-sSUPPORT_LONGJMP=0" + "SHELL:-sSUPPORT_ERRNO=0" + "SHELL:-std=c++20" + "SHELL:-O2" + "SHELL:-g0" + ) + + set_target_properties(lib PROPERTIES OUTPUT_NAME "lib" SUFFIX ".wasm") +endif() +"#; + +fn parse_identity_from_publish_output(publish_output: &str) -> Result { + let re = Regex::new(r"identity: ([0-9a-fA-F]+)").unwrap(); + re.captures(publish_output) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().to_string()) + .context("Failed to parse database identity from publish output") +} + /// A smoketest instance that manages a SpacetimeDB server and module project. pub struct Smoketest { /// The SpacetimeDB server guard (stops server on drop). @@ -962,12 +1019,49 @@ impl Smoketest { ])?; csharp::verify_csharp_module_restore(&module_path)?; - let re = Regex::new(r"identity: ([0-9a-fA-F]+)").unwrap(); - let identity = re - .captures(&publish_output) - .and_then(|caps| caps.get(1)) - .map(|m| m.as_str().to_string()) - .context("Failed to parse database identity from publish output")?; + let identity = parse_identity_from_publish_output(&publish_output)?; + self.database_identity = Some(identity.clone()); + + Ok(identity) + } + + /// Writes and publishes a C++ module from source. + /// + /// The module is created at `/`. + /// On success this updates `self.database_identity`. + pub fn publish_cpp_module_source( + &mut self, + project_dir_name: &str, + module_name: &str, + module_source: &str, + ) -> Result { + let module_path = self.project_dir.path().join(project_dir_name); + let src_dir = module_path.join("src"); + fs::create_dir_all(&src_dir).context("Failed to create C++ source directory")?; + + let bindings_cpp_path = workspace_root() + .join("crates/bindings-cpp") + .display() + .to_string() + .replace('\\', "/"); + let cmakelists = CPP_SMOKETEST_CMAKELISTS.replace("@SPACETIMEDB_CPP_LIBRARY_PATH@", &bindings_cpp_path); + + fs::write(module_path.join("CMakeLists.txt"), cmakelists).context("Failed to write C++ CMakeLists.txt")?; + fs::write(src_dir.join("lib.cpp"), module_source).context("Failed to write C++ module code")?; + + let module_path_str = module_path.to_str().context("Invalid C++ module path")?; + let publish_output = self.spacetime(&[ + "publish", + "--server", + &self.server_url, + "--module-path", + module_path_str, + "--yes", + "--clear-database", + module_name, + ])?; + + let identity = parse_identity_from_publish_output(&publish_output)?; self.database_identity = Some(identity.clone()); Ok(identity) diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 538e7054518..567c3684ac8 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -1,5 +1,5 @@ use regex::Regex; -use spacetimedb_smoketests::{require_dotnet, require_pnpm, workspace_root, Smoketest}; +use spacetimedb_smoketests::{require_dotnet, require_emscripten, require_pnpm, workspace_root, Smoketest}; use std::{fs, path::Path}; const MODULE_CODE: &str = r#" @@ -230,6 +230,335 @@ fn router() -> Router { } "#; +const CPP_MODULE_CODE: &str = r#"#include "spacetimedb.h" + +using namespace SpacetimeDB; + +struct Entry { + uint64_t id; + std::string value; +}; +SPACETIMEDB_STRUCT(Entry, id, value) +SPACETIMEDB_TABLE(Entry, entry, Public) + +namespace { + +std::string header_value_utf8(const HttpRequest& request, const std::string& header_name) { + for (const auto& header : request.headers) { + if (header.name == header_name) { + return std::string(header.value.begin(), header.value.end()); + } + } + return ""; +} + +HttpResponse text_response(uint16_t status_code, std::string body) { + return HttpResponse{ + status_code, + HttpVersion::Http11, + { HttpHeader{"content-type", "text/plain; charset=utf-8"} }, + HttpBody::from_string(body), + }; +} + +} // namespace + +SPACETIMEDB_HTTP_HANDLER(get_simple, HandlerContext ctx, HttpRequest request) { + return text_response(200, "ok"); +} + +SPACETIMEDB_HTTP_HANDLER(post_insert, HandlerContext ctx, HttpRequest request) { + ctx.with_tx([](TxContext& tx) { + uint64_t id = tx.db[entry].count(); + tx.db[entry].insert(Entry{ id, "posted" }); + }); + return text_response(200, "inserted"); +} + +SPACETIMEDB_HTTP_HANDLER(get_count, HandlerContext ctx, HttpRequest request) { + uint64_t count = ctx.with_tx([](TxContext& tx) -> uint64_t { + return tx.db[entry].count(); + }); + return text_response(200, std::to_string(count)); +} + +SPACETIMEDB_HTTP_HANDLER(any_handler, HandlerContext ctx, HttpRequest request) { + return text_response(200, "any"); +} + +SPACETIMEDB_HTTP_HANDLER(header_echo, HandlerContext ctx, HttpRequest request) { + return text_response(200, header_value_utf8(request, "x-echo")); +} + +SPACETIMEDB_HTTP_HANDLER(set_response_header, HandlerContext ctx, HttpRequest request) { + return HttpResponse{ + 200, + HttpVersion::Http11, + { HttpHeader{"x-response", "set"} }, + HttpBody::from_string("header-set"), + }; +} + +SPACETIMEDB_HTTP_HANDLER(body_handler, HandlerContext ctx, HttpRequest request) { + return text_response(200, "non-empty"); +} + +SPACETIMEDB_HTTP_HANDLER(teapot, HandlerContext ctx, HttpRequest request) { + return text_response(418, "teapot"); +} + +SPACETIMEDB_HTTP_ROUTER(router) { + return Router() + .get("/get", get_simple) + .post("/post", post_insert) + .get("/count", get_count) + .any("/any", any_handler) + .get("/header", header_echo) + .get("/set-header", set_response_header) + .get("/body", body_handler) + .get("/teapot", teapot); +} +"#; + +const CPP_EXAMPLE_MODULE_CODE: &str = r#"#include "spacetimedb.h" + +using namespace SpacetimeDB; + +struct Data { + uint64_t id; + std::vector body; +}; +SPACETIMEDB_STRUCT(Data, id, body) +SPACETIMEDB_TABLE(Data, data, Public) +FIELD_PrimaryKeyAutoInc(data, id) + +namespace { + +HttpResponse bytes_response(uint16_t status_code, std::vector body) { + return HttpResponse{ + status_code, + HttpVersion::Http11, + {}, + HttpBody{std::move(body)}, + }; +} + +HttpResponse text_response(uint16_t status_code, std::string body) { + return HttpResponse{ + status_code, + HttpVersion::Http11, + {}, + HttpBody::from_string(body), + }; +} + +std::string query_value(const std::string& uri, const std::string& key) { + std::string needle = "?" + key + "="; + size_t pos = uri.find(needle); + if (pos == std::string::npos) { + needle = "&" + key + "="; + pos = uri.find(needle); + } + if (pos == std::string::npos) { + return ""; + } + pos += needle.size(); + size_t end = uri.find('&', pos); + return uri.substr(pos, end == std::string::npos ? std::string::npos : end - pos); +} + +bool try_parse_u64(const std::string& text, uint64_t& value) { + if (text.empty()) { + return false; + } + uint64_t result = 0; + for (char c : text) { + if (c < '0' || c > '9') { + return false; + } + result = (result * 10) + static_cast(c - '0'); + } + value = result; + return true; +} + +} // namespace + +SPACETIMEDB_HTTP_HANDLER(insert, HandlerContext ctx, HttpRequest request) { + std::vector body = request.body.to_bytes(); + uint64_t id = ctx.with_tx([&](TxContext& tx) -> uint64_t { + return tx.db[data].insert(Data{0, body}).id; + }); + return text_response(200, std::to_string(id)); +} + +SPACETIMEDB_HTTP_HANDLER(retrieve, HandlerContext ctx, HttpRequest request) { + uint64_t id = 0; + if (!try_parse_u64(query_value(request.uri, "id"), id)) { + return text_response(500, "invalid id"); + } + + auto body = ctx.with_tx([&](TxContext& tx) -> std::optional> { + auto row = tx.db[data_id].find(id); + if (row.has_value()) { + return row->body; + } + return std::nullopt; + }); + + if (body.has_value()) { + return bytes_response(200, std::move(body.value())); + } + return bytes_response(404, {}); +} + +SPACETIMEDB_HTTP_ROUTER(router) { + return Router().post("/insert", insert).get("/retrieve", retrieve); +} +"#; + +const CPP_STRICT_ROOT_ROUTING_MODULE_CODE: &str = r#"#include "spacetimedb.h" + +using namespace SpacetimeDB; + +namespace { + +HttpResponse text_response(const std::string& body) { + return HttpResponse{200, HttpVersion::Http11, {}, HttpBody::from_string(body)}; +} + +} // namespace + +SPACETIMEDB_HTTP_HANDLER(empty_root, HandlerContext ctx, HttpRequest request) { + return text_response("empty"); +} + +SPACETIMEDB_HTTP_HANDLER(slash_root, HandlerContext ctx, HttpRequest request) { + return text_response("slash"); +} + +SPACETIMEDB_HTTP_HANDLER(foo, HandlerContext ctx, HttpRequest request) { + return text_response("foo"); +} + +SPACETIMEDB_HTTP_HANDLER(foo_slash, HandlerContext ctx, HttpRequest request) { + return text_response("foo-slash"); +} + +SPACETIMEDB_HTTP_ROUTER(router) { + return Router() + .get("", empty_root) + .get("/", slash_root) + .get("/foo", foo) + .get("/foo/", foo_slash); +} +"#; + +const CPP_STRICT_NON_ROOT_ROUTING_MODULE_CODE: &str = r#"#include "spacetimedb.h" + +using namespace SpacetimeDB; + +namespace { + +HttpResponse text_response(const std::string& body) { + return HttpResponse{200, HttpVersion::Http11, {}, HttpBody::from_string(body)}; +} + +} // namespace + +SPACETIMEDB_HTTP_HANDLER(foo, HandlerContext ctx, HttpRequest request) { + return text_response("foo"); +} + +SPACETIMEDB_HTTP_HANDLER(foo_slash, HandlerContext ctx, HttpRequest request) { + return text_response("foo-slash"); +} + +SPACETIMEDB_HTTP_ROUTER(router) { + return Router() + .get("/foo", foo) + .get("/foo/", foo_slash); +} +"#; + +const CPP_FULL_URI_MODULE_CODE: &str = r#"#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(echo_uri, HandlerContext ctx, HttpRequest request) { + return HttpResponse{ + 200, + HttpVersion::Http11, + {}, + HttpBody::from_string(request.uri), + }; +} + +SPACETIMEDB_HTTP_ROUTER(router) { + return Router().get("/echo-uri", echo_uri); +} +"#; + +const CPP_HANDLE_REQUEST_BODY_MODULE_CODE: &str = r#"#include "spacetimedb.h" +#include + +using namespace SpacetimeDB; + +namespace { + +HttpResponse bytes_response(uint16_t status_code, std::vector body) { + return HttpResponse{status_code, HttpVersion::Http11, {}, HttpBody{std::move(body)}}; +} + +HttpResponse text_response(uint16_t status_code, const std::string& body) { + return HttpResponse{status_code, HttpVersion::Http11, {}, HttpBody::from_string(body)}; +} + +} // namespace + +SPACETIMEDB_HTTP_HANDLER(reverse_bytes, HandlerContext ctx, HttpRequest request) { + std::vector reversed = request.body.to_bytes(); + std::reverse(reversed.begin(), reversed.end()); + return bytes_response(200, std::move(reversed)); +} + +SPACETIMEDB_HTTP_HANDLER(reverse_words, HandlerContext ctx, HttpRequest request) { + const std::vector bytes = request.body.to_bytes(); + std::string body(bytes.begin(), bytes.end()); + if (body.find(static_cast(0x80)) != std::string::npos) { + return text_response(400, "request body must be valid UTF-8"); + } + + std::vector words; + size_t start = 0; + while (true) { + size_t pos = body.find(' ', start); + words.push_back(body.substr(start, pos == std::string::npos ? std::string::npos : pos - start)); + if (pos == std::string::npos) { + break; + } + start = pos + 1; + } + std::reverse(words.begin(), words.end()); + + std::string reversed; + for (size_t i = 0; i < words.size(); ++i) { + if (i != 0) { + reversed += " "; + } + reversed += words[i]; + } + + return text_response(200, reversed); +} + +SPACETIMEDB_HTTP_ROUTER(router) { + return Router() + .post("/reverse-bytes", reverse_bytes) + .post("/reverse-words", reverse_words); +} +"#; + const TS_MODULE_CODE: &str = r#"import { Router, SyncResponse, schema, table, t } from "spacetimedb/server"; const entry = table( @@ -763,6 +1092,13 @@ fn rust_http_test(module_code: &str) -> (Smoketest, String) { (test, identity) } +fn cpp_http_test(name: &str, module_code: &str) -> (Smoketest, String) { + require_emscripten!(); + let mut test = Smoketest::builder().autopublish(false).build(); + let identity = test.publish_cpp_module_source(name, name, module_code).unwrap(); + (test, identity) +} + fn typescript_http_test(name: &str, module_code: &str) -> (Smoketest, String) { require_pnpm!(); let mut test = Smoketest::builder().autopublish(false).build(); @@ -1034,6 +1370,12 @@ fn handle_request_body() { assert_handle_request_body(&test.server_url, &identity); } +#[test] +fn cpp_http_routes_end_to_end() { + let (test, identity) = cpp_http_test("http-routes-cpp-basic", CPP_MODULE_CODE); + assert_http_routes_end_to_end(&test.server_url, &identity); +} + #[test] fn typescript_http_routes_end_to_end() { let (test, identity) = typescript_http_test("http-routes-typescript-basic", TS_MODULE_CODE); @@ -1047,6 +1389,12 @@ fn csharp_http_routes_end_to_end() { assert_http_routes_end_to_end(&test.server_url, &identity); } +#[test] +fn cpp_http_routes_pr_example_round_trip() { + let (test, identity) = cpp_http_test("http-routes-cpp-example", CPP_EXAMPLE_MODULE_CODE); + assert_http_routes_pr_example_round_trip(&test.server_url, &identity); +} + #[test] fn typescript_http_routes_pr_example_round_trip() { let (test, identity) = typescript_http_test("http-routes-typescript-example", TS_EXAMPLE_MODULE_CODE); @@ -1060,6 +1408,15 @@ fn csharp_http_routes_pr_example_round_trip() { assert_http_routes_pr_example_round_trip(&test.server_url, &identity); } +#[test] +fn cpp_http_routes_are_strict_for_non_root_paths() { + let (test, identity) = cpp_http_test( + "http-routes-cpp-strict-non-root", + CPP_STRICT_NON_ROOT_ROUTING_MODULE_CODE, + ); + assert_http_routes_are_strict_for_non_root_paths(&test.server_url, &identity); +} + #[test] fn typescript_http_routes_are_strict_for_non_root_paths() { let (test, identity) = typescript_http_test( @@ -1079,6 +1436,12 @@ fn csharp_http_routes_are_strict_for_non_root_paths() { assert_http_routes_are_strict_for_non_root_paths(&test.server_url, &identity); } +#[test] +fn cpp_http_routes_are_strict_for_root_paths() { + let (test, identity) = cpp_http_test("http-routes-cpp-strict-root", CPP_STRICT_ROOT_ROUTING_MODULE_CODE); + assert_http_routes_are_strict_for_root_paths(&test.server_url, &identity); +} + #[test] fn typescript_http_routes_are_strict_for_root_paths() { let (test, identity) = @@ -1093,6 +1456,12 @@ fn csharp_http_routes_are_strict_for_root_paths() { assert_http_routes_are_strict_for_root_paths(&test.server_url, &identity); } +#[test] +fn cpp_http_handler_observes_full_external_uri() { + let (test, identity) = cpp_http_test("http-routes-cpp-full-uri", CPP_FULL_URI_MODULE_CODE); + assert_http_handler_observes_full_external_uri(&test.server_url, &identity); +} + #[test] fn typescript_http_handler_observes_full_external_uri() { let (test, identity) = typescript_http_test("http-routes-typescript-full-uri", TS_FULL_URI_MODULE_CODE); @@ -1106,6 +1475,12 @@ fn csharp_http_handler_observes_full_external_uri() { assert_http_handler_observes_full_external_uri(&test.server_url, &identity); } +#[test] +fn cpp_handle_request_body() { + let (test, identity) = cpp_http_test("http-routes-cpp-request-body", CPP_HANDLE_REQUEST_BODY_MODULE_CODE); + assert_handle_request_body(&test.server_url, &identity); +} + #[test] fn typescript_handle_request_body() { let (test, identity) = typescript_http_test( @@ -1141,6 +1516,29 @@ fn http_handlers_tutorial_say_hello_route_works() { assert_eq!(resp.text().expect("say-hello body"), "Hello!"); } +/// Validates the C++ example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. +#[test] +fn cpp_http_handlers_tutorial_say_hello_route_works() { + require_emscripten!(); + + let module_code = extract_code_blocks( + &workspace_root().join("docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md"), + r"```(?:cpp|c\+\+)\n([\s\S]*?)\n```", + "cpp", + ); + let mut test = Smoketest::builder().autopublish(false).build(); + let identity = test + .publish_cpp_module_source("http-handlers-docs-cpp", "http-handlers-docs-cpp", &module_code) + .unwrap(); + + let url = format!("{}/v1/database/{identity}/route/say-hello", test.server_url); + let client = reqwest::blocking::Client::new(); + + let resp = client.get(&url).send().expect("say-hello failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("say-hello body"), "Hello!"); +} + /// Validates the TypeScript example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. #[test] fn typescript_http_handlers_tutorial_say_hello_route_works() { diff --git a/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md b/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md index aab6d67690f..c8bc481d338 100644 --- a/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md +++ b/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md @@ -66,6 +66,35 @@ fn say_hello(_ctx: &mut HandlerContext, _req: Request) -> Response { } ``` + + + +Because HTTP handlers are unstable, C++ modules that define them must enable `SPACETIMEDB_UNSTABLE_FEATURES` when compiling. + +Define an HTTP handler with `SPACETIMEDB_HTTP_HANDLER`. + +The function must accept exactly two arguments: + +1. A `SpacetimeDB::HandlerContext`. +2. A `SpacetimeDB::HttpRequest`. + +The function must return a `SpacetimeDB::HttpResponse`. + +```cpp +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(say_hello, HandlerContext ctx, HttpRequest request) { + return HttpResponse{ + 200, + HttpVersion::Http11, + { HttpHeader{"content-type", "text/plain; charset=utf-8"} }, + HttpBody::from_string("Hello!"), + }; +} +``` + @@ -148,6 +177,24 @@ Nest routers with `router.nest(prefix, sub_router)`, which causes `sub_router` t Combine routers with `router.merge(other_router)`, which combines both routers. + + + +All routes exposed by your module are declared in a `SpacetimeDB::Router`. Register the `Router` for your database by returning it from a function defined with `SPACETIMEDB_HTTP_ROUTER`. + +```cpp +SPACETIMEDB_HTTP_ROUTER(router) { + return Router() + .get("/say-hello", say_hello); +} +``` + +Add routes within a router with the `get`, `head`, `options`, `put`, `delete_`, `post`, `patch` and `any` methods, which register an HTTP handler for that HTTP method at a given path. + +Nest routers with `router.nest(prefix, sub_router)`, which causes `sub_router` to handle routing for all paths that start with `prefix`. + +Combine routers with `router.merge(other_router)`, which combines both routers. + From ffb3fb06fd535e688a3d56e778e519b6b2321d1e Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Thu, 28 May 2026 14:09:36 -0400 Subject: [PATCH 46/47] Add basic troubleshooting guide --- .../00300-resources/00050-troubleshooting.md | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/docs/00300-resources/00050-troubleshooting.md diff --git a/docs/docs/00300-resources/00050-troubleshooting.md b/docs/docs/00300-resources/00050-troubleshooting.md new file mode 100644 index 00000000000..07b54a56811 --- /dev/null +++ b/docs/docs/00300-resources/00050-troubleshooting.md @@ -0,0 +1,131 @@ +--- +title: Troubleshooting +slug: /troubleshooting +--- + +This is a list of common problems when using SpacetimeDB and how to fix them. + +## Credentials + +### CLI login not accepted by server + +If your CLI operations fail with the error `Invalid Token: InvalidSignature`, it's likely because you logged in with `--server-issued-login` from a different SpacetimeDB server. It's also possible your server's signing keys have changed, most likely due to the server having been reset. + +Log out to remove the invalid token, then log in again. Logging in with GitHub will prevent this happening again, as those identities are portable and valid with any SpacetimeDB server, including Maincloud. + +```bash +spacetime logout +spacetime login +``` + +:::note +`spacetime logout` will discard your previous server-issued token, resulting in you no longer being able to manage any databases you previously published owned by that identity. If you still need access to the server-issued token, view it with `spacetime login show --token` and save it. You can then log back in with that token using `spacetime login --token`. +::: + +### Client connection rejected + +If SpacetimeDB rejects connections from your application's client, it's most likely because you're supplying a token that was issued by a different SpacetimeDB server, or has expired. Clear the invalid token: + +| Client SDK | How to clear | +|----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Rust (native) | Delete the credentials file for your application from within `~/.spacetimedb_client_credentials`. Check where you construct the `spacetimedb_sdk::credentials::File` to find which file to delete. | +| C# (native) | Delete the `auth_token=` line from your app's saved settings, by default located in `~/.spacetime_csharp_sdk/settings.ini`. Check your call to `AuthToken.Init` to find the directory and/or file name. | +| TypeScript, Rust or C# (browser) | Clear cookies. | +| Unity | Clear PlayerPrefs by selecting **Edit -> Clear All PlayerPrefs** | +| Unreal | Delete the file `Saved/Config/WindowsEditor/GameUserSettings.ini` in your Unreal project. | + +## In Modules + +### Identity seen by reducers never changes + +In module code, `ctx.identity()` (or `Ctx.Identity()`) reads the identity of the database, not of the connected client. Call `ctx.sender()`/`Ctx.Sender()` instead to read the identity of the client which requested the current function call. + +As of SpacetimeDB 2.3.0, the `identity` method is a deprecated alias for `ctx.database_identity()`/`ctx.databaseIdentity()`/`Ctx.DatabaseIdentity()`. + +### Code changes not taking effect + +If you've made changes to your module code that aren't taking effect, you likely need to publish the new version of the code to the database. Use the `spacetime publish` CLI command, or run `spacetime dev`, which will watch your project and automatically call `spacetime publish` whenever you make changes. + +## In Clients + +### Connection completely unresponsive + +If your `DbConnection` is completely unresponsive, with function calls never receiving responses and subscriptions never being applied, you may have one of several issues: + +#### Connection rejected + +Your client may have failed to connect to the remote server, or may have been disconnected soon after starting. Make sure you register `on_connect_error`/`onConnectError`/`OnConnectError` and `on_disconnect`/`onDisconnect`/`OnDisconnect` callbacks when building your `DbConnection`, and check if they're being invoked with errors. + +#### Connection not advancing + +You may need to advance your connection by calling one of the following methods: + +| Client SDK | Method | Description | +|---------------------|------------------------------|------------------------------------------------------------------------------------| +| Rust (native only) | `conn.run_threaded()` | Spawn a thread to continuously advance the connection. | +| Rust (browser only) | `conn.run_background_task()` | Spawn a task to continuously advance the connection. | +| Rust | `conn.run_async()` | A `Future` which you can `await` or poll to advance the connection. | +| Rust | `conn.frame_tick()` | In single-threaded games, call this every frame to advance the connection. | +| C# | `Conn.FrameTick()` | Call this every frame to advance the connection, or call it in a loop on a thread. | +| Unreal | `Conn.FrameTick()` | Call this every frame to advance the connection. | +| TypeScript | N/a | The TypeScript client SDK advances connections automatically. | + +### Rows never appear + +If rows from a table or view never appear and row callbacks are never invoked, you may need to add a subscription, or a subscription may have failed. + +#### Add a subscription + +In order for rows to be visible to a client, you must subscribe to a table or view. See [Subscriptions](../00200-core-concepts/00400-subscriptions.md). + +#### Check for subscription errors + +If you've subscribed to a subscription but never see any rows, your subscription may have failed. Ensure you're registering an `on_error`/`onError`/`OnError` callback with the subscription builder, and check if it is invoked. + +### Insert, update or delete callback not invoked when row changes + +For a table or view with a primary key, a row change may be routed to either the on-insert, on-update or on-delete callback. Each change will only cause one of these callbacks to be invoked, so make sure you've registered all three of them. + +:::note +Insert, update and delete callbacks on the client don't always correspond 1-to-1 with calls to those methods in the module code. + +The client may see an update event when you call delete followed by insert within the same reducer or transaction. + +The client may see an insert or a delete event when you call update but either the old or new version of the row doesn't match the client's subscriptions. +::: + +For tables without primary keys, only the on-insert and on-delete callbacks will ever be invoked. + +### Row seen by update callback is out of date + +If it appears that an on-update callback is observing an old version of a row, you may have mixed up the parameters to that callback. Update callbacks take three parameter: an `EventContext` for interacting with the connection, the old version of the row, and the new version of the row. Make sure the function you register to the callback has 3 arguments:`(ctx, old, new)`. + +### Serialization errors + +If you see errors related to serialization, including unexpected EOFs, incorrect lengths or unrecognized tags, it's likely your generated `module_bindings` are out of date. Re-run `spacetime generate` to update them, or use `spacetime dev`, which will watch your module code and automatically call `spacetime generate` whenever you make changes. + +### Reducers, procedures, views not visible to client + +Scheduled reducers or procedures won't show up in client codegen. That's expected; clients can't directly invoke them. + +If functions that aren't scheduled aren't showing up, or views aren't visible, it's likely your generated `module_bindings` are out of date. Re-run `spacetime generate` to update them, or use `spacetime dev`, which will watch your module code and automatically call `spacetime generate` whenever you make changes. + +### Tables not visible to client + +If a table isn't visible in client codegen, and you've already run `spacetime generate` or are using `spacetime dev`, the table may be private. Only tables marked public will be visible to clients and are available for subscriptions. See [Defining Tables](../00200-core-concepts/00300-tables.md#defining-tables) for how to mark a table public. + +### Compilation or type errors in generated `module_bindings` + +If you see errors when compiling or type checking your autogenerated `module_bindings`, it's likely that your SpacetimeDB CLI version doesn't match the client SDK you're using in your client project. + +Ensure that your CLI is up to date by running `spacetime version upgrade`, then check your version with `spacetime --version`. + +Update to the latest version of the CLI package in your client dependencies: + +| Client SDK | Dependency file | Package name | +|------------|-----------------------|------------------------------------| +| Rust | `Cargo.toml` | `spacetimedb-sdk` | +| TypeScript | `package.json` | `"spacetimedb"` | +| C# | `.csproj` | `"SpacetimeDB.ClientSDK"` | +| Unity | Unity Package Manager | `com.clockworklabs.spacetimedbsdk` | +| Unreal | `.Build.cs` | `"SpacetimeDbSdk"` | From e84737cbd23fc03adbb3553fc3268a832593f66d Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Thu, 28 May 2026 14:10:17 -0400 Subject: [PATCH 47/47] Revert "Add basic troubleshooting guide" This reverts commit ffb3fb06fd535e688a3d56e778e519b6b2321d1e. This was supposed to be on a different branch. Oops. --- .../00300-resources/00050-troubleshooting.md | 131 ------------------ 1 file changed, 131 deletions(-) delete mode 100644 docs/docs/00300-resources/00050-troubleshooting.md diff --git a/docs/docs/00300-resources/00050-troubleshooting.md b/docs/docs/00300-resources/00050-troubleshooting.md deleted file mode 100644 index 07b54a56811..00000000000 --- a/docs/docs/00300-resources/00050-troubleshooting.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: Troubleshooting -slug: /troubleshooting ---- - -This is a list of common problems when using SpacetimeDB and how to fix them. - -## Credentials - -### CLI login not accepted by server - -If your CLI operations fail with the error `Invalid Token: InvalidSignature`, it's likely because you logged in with `--server-issued-login` from a different SpacetimeDB server. It's also possible your server's signing keys have changed, most likely due to the server having been reset. - -Log out to remove the invalid token, then log in again. Logging in with GitHub will prevent this happening again, as those identities are portable and valid with any SpacetimeDB server, including Maincloud. - -```bash -spacetime logout -spacetime login -``` - -:::note -`spacetime logout` will discard your previous server-issued token, resulting in you no longer being able to manage any databases you previously published owned by that identity. If you still need access to the server-issued token, view it with `spacetime login show --token` and save it. You can then log back in with that token using `spacetime login --token`. -::: - -### Client connection rejected - -If SpacetimeDB rejects connections from your application's client, it's most likely because you're supplying a token that was issued by a different SpacetimeDB server, or has expired. Clear the invalid token: - -| Client SDK | How to clear | -|----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Rust (native) | Delete the credentials file for your application from within `~/.spacetimedb_client_credentials`. Check where you construct the `spacetimedb_sdk::credentials::File` to find which file to delete. | -| C# (native) | Delete the `auth_token=` line from your app's saved settings, by default located in `~/.spacetime_csharp_sdk/settings.ini`. Check your call to `AuthToken.Init` to find the directory and/or file name. | -| TypeScript, Rust or C# (browser) | Clear cookies. | -| Unity | Clear PlayerPrefs by selecting **Edit -> Clear All PlayerPrefs** | -| Unreal | Delete the file `Saved/Config/WindowsEditor/GameUserSettings.ini` in your Unreal project. | - -## In Modules - -### Identity seen by reducers never changes - -In module code, `ctx.identity()` (or `Ctx.Identity()`) reads the identity of the database, not of the connected client. Call `ctx.sender()`/`Ctx.Sender()` instead to read the identity of the client which requested the current function call. - -As of SpacetimeDB 2.3.0, the `identity` method is a deprecated alias for `ctx.database_identity()`/`ctx.databaseIdentity()`/`Ctx.DatabaseIdentity()`. - -### Code changes not taking effect - -If you've made changes to your module code that aren't taking effect, you likely need to publish the new version of the code to the database. Use the `spacetime publish` CLI command, or run `spacetime dev`, which will watch your project and automatically call `spacetime publish` whenever you make changes. - -## In Clients - -### Connection completely unresponsive - -If your `DbConnection` is completely unresponsive, with function calls never receiving responses and subscriptions never being applied, you may have one of several issues: - -#### Connection rejected - -Your client may have failed to connect to the remote server, or may have been disconnected soon after starting. Make sure you register `on_connect_error`/`onConnectError`/`OnConnectError` and `on_disconnect`/`onDisconnect`/`OnDisconnect` callbacks when building your `DbConnection`, and check if they're being invoked with errors. - -#### Connection not advancing - -You may need to advance your connection by calling one of the following methods: - -| Client SDK | Method | Description | -|---------------------|------------------------------|------------------------------------------------------------------------------------| -| Rust (native only) | `conn.run_threaded()` | Spawn a thread to continuously advance the connection. | -| Rust (browser only) | `conn.run_background_task()` | Spawn a task to continuously advance the connection. | -| Rust | `conn.run_async()` | A `Future` which you can `await` or poll to advance the connection. | -| Rust | `conn.frame_tick()` | In single-threaded games, call this every frame to advance the connection. | -| C# | `Conn.FrameTick()` | Call this every frame to advance the connection, or call it in a loop on a thread. | -| Unreal | `Conn.FrameTick()` | Call this every frame to advance the connection. | -| TypeScript | N/a | The TypeScript client SDK advances connections automatically. | - -### Rows never appear - -If rows from a table or view never appear and row callbacks are never invoked, you may need to add a subscription, or a subscription may have failed. - -#### Add a subscription - -In order for rows to be visible to a client, you must subscribe to a table or view. See [Subscriptions](../00200-core-concepts/00400-subscriptions.md). - -#### Check for subscription errors - -If you've subscribed to a subscription but never see any rows, your subscription may have failed. Ensure you're registering an `on_error`/`onError`/`OnError` callback with the subscription builder, and check if it is invoked. - -### Insert, update or delete callback not invoked when row changes - -For a table or view with a primary key, a row change may be routed to either the on-insert, on-update or on-delete callback. Each change will only cause one of these callbacks to be invoked, so make sure you've registered all three of them. - -:::note -Insert, update and delete callbacks on the client don't always correspond 1-to-1 with calls to those methods in the module code. - -The client may see an update event when you call delete followed by insert within the same reducer or transaction. - -The client may see an insert or a delete event when you call update but either the old or new version of the row doesn't match the client's subscriptions. -::: - -For tables without primary keys, only the on-insert and on-delete callbacks will ever be invoked. - -### Row seen by update callback is out of date - -If it appears that an on-update callback is observing an old version of a row, you may have mixed up the parameters to that callback. Update callbacks take three parameter: an `EventContext` for interacting with the connection, the old version of the row, and the new version of the row. Make sure the function you register to the callback has 3 arguments:`(ctx, old, new)`. - -### Serialization errors - -If you see errors related to serialization, including unexpected EOFs, incorrect lengths or unrecognized tags, it's likely your generated `module_bindings` are out of date. Re-run `spacetime generate` to update them, or use `spacetime dev`, which will watch your module code and automatically call `spacetime generate` whenever you make changes. - -### Reducers, procedures, views not visible to client - -Scheduled reducers or procedures won't show up in client codegen. That's expected; clients can't directly invoke them. - -If functions that aren't scheduled aren't showing up, or views aren't visible, it's likely your generated `module_bindings` are out of date. Re-run `spacetime generate` to update them, or use `spacetime dev`, which will watch your module code and automatically call `spacetime generate` whenever you make changes. - -### Tables not visible to client - -If a table isn't visible in client codegen, and you've already run `spacetime generate` or are using `spacetime dev`, the table may be private. Only tables marked public will be visible to clients and are available for subscriptions. See [Defining Tables](../00200-core-concepts/00300-tables.md#defining-tables) for how to mark a table public. - -### Compilation or type errors in generated `module_bindings` - -If you see errors when compiling or type checking your autogenerated `module_bindings`, it's likely that your SpacetimeDB CLI version doesn't match the client SDK you're using in your client project. - -Ensure that your CLI is up to date by running `spacetime version upgrade`, then check your version with `spacetime --version`. - -Update to the latest version of the CLI package in your client dependencies: - -| Client SDK | Dependency file | Package name | -|------------|-----------------------|------------------------------------| -| Rust | `Cargo.toml` | `spacetimedb-sdk` | -| TypeScript | `package.json` | `"spacetimedb"` | -| C# | `.csproj` | `"SpacetimeDB.ClientSDK"` | -| Unity | Unity Package Manager | `com.clockworklabs.spacetimedbsdk` | -| Unreal | `.Build.cs` | `"SpacetimeDbSdk"` |