Skip to content

Commit 5c04860

Browse files
gefjonclockwork-labs-botJasonAtClockwork
authored
Implement HTTP handlers / webhooks in Rust modules (#4636)
# Description of Changes Adds support to Rust modules and the SpacetimeDB host for defining HTTP handlers and registering them to routes. ## User-facing API In a Rust module, users can annotate functions with the new macro `#[spacetimedb::http::handler]`. A function annotated this way must accept exactly two arguments, of types `&mut spacetimedb::http::HandlerContext` and `spacetimedb::http::Request` (which is a type alias for `http::Request<spacetimedb::http::Body>`. It must also return `spacetimedb::http::Response` (which is a type alias for `http::Response<spacetimedb::http::Body>`). Once the user has defined an HTTP handler, they can register it to a route by annotating a function with `#[spacetimedb::http::router]`. Such a function must take no arguments and return a `spacetimedb::http::Router`. (The original design put this annotation on a `static` variable rather than a function, but that turned out to be undesirable because it required that constructing a `Router` be `const`.) `Router` exposes various methods for registering handlers to routes. All of a database's user-defined routes are exposed under `/v1/database/:name_or_identity/route/{*path}`. ## Example See [the new smoketest](https://github.com/clockworklabs/SpacetimeDB/blob/phoebe/http-handlers-webhooks/crates/smoketests/tests/smoketests/http_routes.rs) for a more exhaustive example. A simpler example, which stores arbitrary byte data in a table via a `POST` request, returns an ID, and then retrieves that same data via a `GET` request with a query parameter: ```rust #[spacetimedb::table(accessor = data)] struct Data { #[primary_key] #[auto_inc] id: u64, body: Vec<u8>, } #[spacetimedb::http::handler] fn insert(ctx: &mut HandlerContext, request: Request) -> Response { let body: Vec<u8> = 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) } ``` ## Design and implementation notes - As mentioned above, the router is registered via a function, not a `static` or `const` item. This is because `static` or `const` initializers must be `const`, and it turns out to be a pain to make all of the `Router` constructors be `const fn`s. - The `#[handler]` macro clobbers the original function name with a `const` variable of type `HttpHandler`. This is unfortunate, but AFAICT necessary, 'cause we need to pass the string identifier for the handler to the `Router`, not the function pointer, and Rust allows no (stable and reliable) way to get a unique string identifier out of a function item/value, nor to attach data or implement traits for function items/values. The alternative(s) would involve changing the signature of the `Router` methods to have uglier and more complex callsites, e.g. like `.get("/retrieve", retrieve::handler())`, `.get("/retrieve", handler!(retrieve))` or `.get::<retrieve>("/retrieve")`. I believe that registering handlers will be much more common than calling their functions, so I've chosen to make it so that registering them gets the convenient syntax, even though the inability to call them directly will be somewhat surprising. - I haven't wired up energy handling or timing metrics for handler execution to anywhere. Procedures are still in the same boat. - HTTP requests to user-defined routes bypass the usual SpacetimeDB auth middleware, meaning that the host does not validate (or inspect in any way) `Authorization` headers in requests before invoking the user-defined handler. This is required to allow arbitrary user-programmable handling of `Authorization` headers, including those in formations which SpacetimeDB would reject. As a result of this, `HandlerContext` doesn't expose a `sender` or `sender_connection_id`. - HTTP route paths may consist only of a very restrictive set of characters. I've chosen this set to keep our options open in the future to add additional syntax to routes, like for registering wildcard segments and path parameters: - ASCII digits. - ASCII letters. - `-_~/`. - The internal data structure that represents a `Router` is currently a `Vec<Route>`, meaning that resolving a request to a route is `O(num_routes)`. Registering a route checks against each previous route for uniqueness, meaning that constructing a router is `O(num_routes ^ 2)`. There are TODO comments to use a trie, but I think this can wait, as I expect most databases to register few routes. - Commit 999a7c3 contains a fix to a mostly-unrelated bug where a few bindings introduced by the SATS derive macros were unhygienic and not in a reserved namespace, leading to name conflicts. I discovered this 'cause I tried writing an HTTP handler named `index` to serve the index/root of a website and it broke. ## Still TODO - [x] Resolve various TODO comments in the diff. - [x] Documentation. - [x] C# bindings support. - [x] C++ bindings support. - [x] V8 host support. - [x] TypeScript bindings support. # API and ABI breaking changes New APIs, currently flagged as `unstable`, which will eventually need stability guarantees. No (intentional) breaking changes, or changes to existing APIs at all. # Expected complexity level and risk 3? Changes to our HTTP routing to support the user-defined routes. # Testing <!-- Describe any testing you've done, and any testing you'd like your reviewers to do, so that you're confident that all the changes work as expected! --> - [x] New smoketest of the behavior! - [x] I dunno, maybe try hosting a simple webpage and see how it works? - [x] Build a test app with Stripe integration. - @aasoni did this. --------- Co-authored-by: clockwork-labs-bot <clockwork-labs-bot@users.noreply.github.com> Co-authored-by: Jason Larabie <jason@clockworklabs.io>
1 parent 13a2ffd commit 5c04860

143 files changed

Lines changed: 9591 additions & 736 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

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

crates/bindings-cpp/CMakeLists.txt

Lines changed: 3 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ target_sources(spacetimedb_cpp_library PRIVATE ${LIBRARY_SOURCES})
3030

3131
# Require C++20 for consumers of this library without forcing global flags
3232
target_compile_features(spacetimedb_cpp_library PUBLIC cxx_std_20)
33+
target_compile_definitions(spacetimedb_cpp_library PRIVATE SPACETIMEDB_UNSTABLE_FEATURES)
3334

3435
# Set include directories
3536
target_include_directories(spacetimedb_cpp_library
@@ -60,46 +61,5 @@ if(PROJECT_IS_TOP_LEVEL)
6061
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
6162
endif()
6263

63-
# ---- Tests ----
64-
# Default: ON only when building this project directly; OFF when used via FetchContent/add_subdirectory
65-
if(CMAKE_VERSION VERSION_LESS 3.21)
66-
# Fallback heuristic for older CMake
67-
set(_is_top_level FALSE)
68-
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
69-
set(_is_top_level TRUE)
70-
endif()
71-
else()
72-
set(_is_top_level ${PROJECT_IS_TOP_LEVEL})
73-
endif()
74-
75-
option(BUILD_TESTS "Build the test suite" ${_is_top_level})
76-
77-
if(BUILD_TESTS AND NOT CMAKE_SYSTEM_NAME STREQUAL "Emscripten")
78-
enable_testing()
79-
80-
# Add test executable
81-
add_executable(test_bsatn tests/main.cpp tests/module_library_unit_tests.cpp)
82-
83-
# Link against the module library
84-
target_link_libraries(test_bsatn PRIVATE spacetimedb_cpp_library)
85-
86-
# Set C++20 standard for tests
87-
target_compile_features(test_bsatn PRIVATE cxx_std_20)
88-
89-
# Add test to CTest
90-
add_test(NAME bsatn_tests COMMAND test_bsatn)
91-
92-
# Add verbose test variant
93-
add_test(NAME bsatn_tests_verbose COMMAND test_bsatn -v)
94-
95-
# Set test properties
96-
set_tests_properties(bsatn_tests PROPERTIES
97-
TIMEOUT 30
98-
LABELS "unit"
99-
)
100-
101-
set_tests_properties(bsatn_tests_verbose PROPERTIES
102-
TIMEOUT 30
103-
LABELS "unit;verbose"
104-
)
105-
endif()
64+
# Unit/compile/smoke test harnesses live under `tests/` as standalone runners
65+
# rather than being built through the top-level library CMake target.

crates/bindings-cpp/include/spacetimedb.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@
126126

127127
// Procedure context and macros
128128
#include "spacetimedb/procedure_macros.h"
129+
#ifdef SPACETIMEDB_UNSTABLE_FEATURES
130+
#include "spacetimedb/handler_context.h"
131+
#include "spacetimedb/router.h"
132+
#include "spacetimedb/http_handler_macros.h"
133+
#endif
129134

130135
// =============================================================================
131136
// VIEW SYSTEM

crates/bindings-cpp/include/spacetimedb/abi/abi.h

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,37 @@ int16_t __call_reducer__(
216216
BytesSource args,
217217
BytesSink error);
218218

219+
STDB_EXPORT(__call_view__)
220+
int16_t __call_view__(
221+
uint32_t id,
222+
uint64_t sender_0, uint64_t sender_1, uint64_t sender_2, uint64_t sender_3,
223+
BytesSource args,
224+
BytesSink result);
225+
226+
STDB_EXPORT(__call_view_anon__)
227+
int16_t __call_view_anon__(
228+
uint32_t id,
229+
BytesSource args,
230+
BytesSink result);
231+
232+
STDB_EXPORT(__call_procedure__)
233+
int16_t __call_procedure__(
234+
uint32_t id,
235+
uint64_t sender_0, uint64_t sender_1, uint64_t sender_2, uint64_t sender_3,
236+
uint64_t conn_id_0, uint64_t conn_id_1,
237+
uint64_t timestamp_microseconds,
238+
BytesSource args_source,
239+
BytesSink result_sink);
240+
241+
STDB_EXPORT(__call_http_handler__)
242+
int16_t __call_http_handler__(
243+
uint32_t id,
244+
uint64_t timestamp_microseconds,
245+
BytesSource request_source,
246+
BytesSource request_body_source,
247+
BytesSink response_sink,
248+
BytesSink response_body_sink);
249+
219250
// ========================================================================
220251
// WASI SHIMS
221252
// ========================================================================
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#ifndef SPACETIMEDB_HANDLER_CONTEXT_H
2+
#define SPACETIMEDB_HANDLER_CONTEXT_H
3+
4+
#ifndef SPACETIMEDB_UNSTABLE_FEATURES
5+
#error "spacetimedb/handler_context.h requires SPACETIMEDB_UNSTABLE_FEATURES to be enabled"
6+
#endif
7+
8+
#include <spacetimedb/abi/FFI.h>
9+
#include <spacetimedb/bsatn/timestamp.h>
10+
#include <spacetimedb/bsatn/uuid.h>
11+
#include <spacetimedb/http.h>
12+
#include <spacetimedb/internal/tx_execution.h>
13+
#include <spacetimedb/random.h>
14+
#include <spacetimedb/tx_context.h>
15+
#include <array>
16+
#include <cstdint>
17+
#include <functional>
18+
#include <memory>
19+
#include <optional>
20+
#include <type_traits>
21+
22+
namespace SpacetimeDB {
23+
24+
struct HandlerContext {
25+
Timestamp timestamp;
26+
HttpClient http;
27+
28+
private:
29+
mutable std::shared_ptr<StdbRng> rng_instance;
30+
mutable uint32_t counter_uuid_ = 0;
31+
32+
public:
33+
HandlerContext() = default;
34+
explicit HandlerContext(Timestamp t) : timestamp(t) {}
35+
36+
Identity identity() const {
37+
std::array<uint8_t, 32> id_bytes;
38+
::identity(id_bytes.data());
39+
return Identity(id_bytes);
40+
}
41+
42+
StdbRng& rng() const {
43+
if (!rng_instance) {
44+
rng_instance = std::make_shared<StdbRng>(timestamp);
45+
}
46+
return *rng_instance;
47+
}
48+
49+
Uuid new_uuid_v4() const {
50+
std::array<uint8_t, 16> random_bytes;
51+
rng().fill_bytes(random_bytes.data(), random_bytes.size());
52+
return Uuid::from_random_bytes_v4(random_bytes);
53+
}
54+
55+
Uuid new_uuid_v7() const {
56+
std::array<uint8_t, 4> random_bytes;
57+
rng().fill_bytes(random_bytes.data(), random_bytes.size());
58+
return Uuid::from_counter_v7(counter_uuid_, timestamp, random_bytes);
59+
}
60+
61+
#ifdef SPACETIMEDB_UNSTABLE_FEATURES
62+
template<typename Func>
63+
auto with_tx(Func&& body) -> decltype(body(std::declval<TxContext&>())) {
64+
auto make_reducer_ctx = [](Timestamp tx_timestamp) {
65+
return ReducerContext(
66+
Identity{},
67+
std::nullopt,
68+
tx_timestamp,
69+
AuthCtx::internal()
70+
);
71+
};
72+
return Internal::with_tx(make_reducer_ctx, body);
73+
}
74+
75+
template<typename Func>
76+
auto try_with_tx(Func&& body) -> decltype(body(std::declval<TxContext&>())) {
77+
auto make_reducer_ctx = [](Timestamp tx_timestamp) {
78+
return ReducerContext(
79+
Identity{},
80+
std::nullopt,
81+
tx_timestamp,
82+
AuthCtx::internal()
83+
);
84+
};
85+
return Internal::try_with_tx(make_reducer_ctx, body);
86+
}
87+
#endif
88+
};
89+
90+
} // namespace SpacetimeDB
91+
92+
#endif // SPACETIMEDB_HANDLER_CONTEXT_H

crates/bindings-cpp/include/spacetimedb/http.h

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33

44
#pragma once
55

6+
#ifndef SPACETIMEDB_UNSTABLE_FEATURES
7+
#error "spacetimedb/http.h requires SPACETIMEDB_UNSTABLE_FEATURES to be enabled"
8+
#endif
9+
610
#include <string>
711
#include <vector>
812
#include <optional>
@@ -312,8 +316,10 @@ class HttpClient {
312316

313317
} // namespace SpacetimeDB
314318

315-
// Include implementation after class definition to avoid circular dependencies
316-
#ifdef SPACETIMEDB_UNSTABLE_FEATURES
319+
// Include implementation dependencies after class definition to avoid circular dependencies
320+
#if defined(SPACETIMEDB_UNSTABLE_FEATURES) && !defined(SPACETIMEDB_HTTP_CONVERT_H)
321+
#include "spacetimedb/logger.h"
322+
#include "spacetimedb/http_convert.h"
317323
#include "spacetimedb/http_client_impl.h"
318324
#endif
319325

crates/bindings-cpp/include/spacetimedb/http_client_impl.h

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
#include "spacetimedb/http_convert.h"
88
#include "spacetimedb/abi/abi.h"
99
#include "spacetimedb/bsatn/bsatn.h"
10-
#include "spacetimedb/internal/Module.h"
10+
#include "spacetimedb/internal/runtime_registration.h"
1111

1212
namespace SpacetimeDB {
1313

@@ -23,9 +23,9 @@ inline Outcome<HttpResponse> HttpClient::SendImpl(const HttpRequest& request) {
2323
// Prepare body bytes
2424
const std::vector<uint8_t>& body_bytes = request.body.bytes;
2525

26-
// Call host function
27-
// Note: For empty body, we need to pass a valid pointer, not null
28-
const uint8_t* body_ptr = body_bytes.empty() ? reinterpret_cast<const uint8_t*>("") : body_bytes.data();
26+
// The host ABI requires a non-null, in-bounds body pointer even when body_len == 0.
27+
static const uint8_t empty_sentinel = 0;
28+
const uint8_t* body_ptr = body_bytes.empty() ? &empty_sentinel : body_bytes.data();
2929

3030
BytesSource out[2] = {BytesSource{0}, BytesSource{0}};
3131
Status status = procedure_http_request(
@@ -40,15 +40,11 @@ inline Outcome<HttpResponse> HttpClient::SendImpl(const HttpRequest& request) {
4040
if (status.inner == 21) {
4141
// Read error message from out[0]
4242
std::vector<uint8_t> error_bytes = Internal::ConsumeBytes(out[0]);
43-
44-
LOG_INFO("HTTP: Error bytes: " + std::to_string(error_bytes.size()));
45-
43+
4644
// Decode BSATN string
4745
bsatn::Reader reader(error_bytes.data(), error_bytes.size());
4846
std::string error_message = bsatn::deserialize<std::string>(reader);
4947

50-
LOG_INFO("HTTP: Error message: " + error_message);
51-
5248
return Err<HttpResponse>(std::move(error_message));
5349
}
5450

@@ -57,7 +53,6 @@ inline Outcome<HttpResponse> HttpClient::SendImpl(const HttpRequest& request) {
5753
return Err<HttpResponse>("HTTP requests are blocked inside transactions. Call HTTP before with_tx() or try_with_tx().");
5854
}
5955

60-
LOG_INFO("HTTP: Unknown error code: " + std::to_string(status.inner));
6156
return Err<HttpResponse>("HTTP request failed with status code: " + std::to_string(status.inner));
6257
}
6358

crates/bindings-cpp/include/spacetimedb/http_convert.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,12 @@ inline HttpRequest from_wire(const wire::HttpRequest& request) {
237237
return result;
238238
}
239239

240+
inline HttpRequest from_wire(const wire::HttpRequest& request, std::vector<uint8_t> body) {
241+
HttpRequest result = from_wire(request);
242+
result.body.bytes = std::move(body);
243+
return result;
244+
}
245+
240246
// ==================== HttpResponse Conversions ====================
241247

242248
/**
@@ -268,7 +274,16 @@ inline HttpResponse from_wire(const wire::HttpResponse& response) {
268274
return result;
269275
}
270276

277+
inline std::pair<wire::HttpResponse, std::vector<uint8_t>> to_wire_split(const HttpResponse& response) {
278+
return {to_wire(response), response.body.bytes};
279+
}
280+
271281
} // namespace convert
272282
} // namespace SpacetimeDB
273283

284+
#ifdef SPACETIMEDB_UNSTABLE_FEATURES
285+
#include "spacetimedb/logger.h"
286+
#include "spacetimedb/http_client_impl.h"
287+
#endif
288+
274289
#endif // SPACETIMEDB_HTTP_CONVERT_H
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#pragma once
2+
3+
#ifndef SPACETIMEDB_UNSTABLE_FEATURES
4+
#error "spacetimedb/http_handler_macros.h requires SPACETIMEDB_UNSTABLE_FEATURES to be enabled"
5+
#endif
6+
7+
#include "spacetimedb/handler_context.h"
8+
#include "spacetimedb/http.h"
9+
#include "spacetimedb/internal/runtime_registration.h"
10+
#include "spacetimedb/internal/template_utils.h"
11+
#include "spacetimedb/internal/v10_builder.h"
12+
#include "spacetimedb/macros.h"
13+
#include "spacetimedb/router.h"
14+
15+
namespace SpacetimeDB::Internal {
16+
17+
template<typename Func>
18+
inline void RegisterHttpHandlerMacro(const char* handler_name, Func func) {
19+
using traits = function_traits<Func>;
20+
static_assert(traits::arity == 2, "HTTP handlers must take exactly two arguments");
21+
using ContextType = typename traits::template arg_t<0>;
22+
using RequestType = typename traits::template arg_t<1>;
23+
using ReturnType = typename traits::result_type;
24+
static_assert(std::is_same_v<ContextType, HandlerContext>, "First parameter of HTTP handler must be HandlerContext");
25+
static_assert(std::is_same_v<RequestType, HttpRequest>, "Second parameter of HTTP handler must be HttpRequest");
26+
static_assert(std::is_same_v<ReturnType, HttpResponse>, "HTTP handlers must return HttpResponse");
27+
28+
std::function<HttpResponse(HandlerContext&, HttpRequest)> handler =
29+
[func](HandlerContext& ctx, HttpRequest request) -> HttpResponse {
30+
return func(ctx, std::move(request));
31+
};
32+
RegisterHttpHandlerHandler(handler_name, func, std::move(handler));
33+
getV10Builder().RegisterHttpHandlerDef(handler_name);
34+
}
35+
36+
} // namespace SpacetimeDB::Internal
37+
38+
#define SPACETIMEDB_HTTP_HANDLER(handler_name, ctx_param, request_param) \
39+
SpacetimeDB::HttpResponse handler_name(ctx_param, request_param); \
40+
__attribute__((export_name("__preinit__60_http_handler_" #handler_name))) \
41+
extern "C" void CONCAT(_spacetimedb_preinit_register_http_handler_, handler_name)() { \
42+
::SpacetimeDB::Internal::RegisterHttpHandlerMacro(#handler_name, handler_name); \
43+
} \
44+
SpacetimeDB::HttpResponse handler_name(ctx_param, request_param)
45+
46+
#define SPACETIMEDB_HTTP_HANDLER_NAMED(handler_name, canonical_name, ctx_param, request_param) \
47+
SpacetimeDB::HttpResponse handler_name(ctx_param, request_param); \
48+
__attribute__((export_name("__preinit__60_http_handler_" #handler_name))) \
49+
extern "C" void CONCAT(_spacetimedb_preinit_register_http_handler_, handler_name)() { \
50+
::SpacetimeDB::Internal::RegisterHttpHandlerMacro(#handler_name, handler_name); \
51+
SpacetimeDB::Module::RegisterExplicitFunctionName(#handler_name, canonical_name); \
52+
} \
53+
SpacetimeDB::HttpResponse handler_name(ctx_param, request_param)
54+
55+
#define SPACETIMEDB_HTTP_ROUTER(router_name) \
56+
SpacetimeDB::Router router_name(); \
57+
__attribute__((export_name("__preinit__61_http_router_" #router_name))) \
58+
extern "C" void CONCAT(_spacetimedb_preinit_register_http_router_, router_name)() { \
59+
::SpacetimeDB::Internal::getV10Builder().RegisterHttpRouter(router_name()); \
60+
} \
61+
SpacetimeDB::Router router_name()

0 commit comments

Comments
 (0)