diff --git a/Cargo.lock b/Cargo.lock index f952227d74f..862a2f4d67c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7898,6 +7898,7 @@ dependencies = [ "futures", "headers", "http 1.3.1", + "http-body-util", "humantime", "hyper 1.7.0", "hyper-util", @@ -7929,6 +7930,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite 0.27.0", "toml 0.8.23", + "tower", "tower-http 0.5.2", "tower-layer", "tower-service", @@ -8617,6 +8619,7 @@ dependencies = [ "cargo_metadata", "predicates", "regex", + "reqwest 0.12.24", "serde_json", "spacetimedb-core", "spacetimedb-guard", diff --git a/crates/bindings-cpp/CMakeLists.txt b/crates/bindings-cpp/CMakeLists.txt index 656b692d95b..46bec094631 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/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/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 )>; } 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/bindings-macro/src/http.rs b/crates/bindings-macro/src/http.rs new file mode 100644 index 00000000000..b5cfb538bab --- /dev/null +++ b/crates/bindings-macro/src/http.rs @@ -0,0 +1,113 @@ +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")?; + + 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]; + + 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 + + #[allow(non_upper_case_globals)] + #vis const #func_name: spacetimedb::http::Handler = spacetimedb::http::Handler::new(#handler_name); + + const _: () = { + #generated_describe_function + }; + + const _: () = { + 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() { + let _f: fn() -> spacetimedb::http::Router = #func_name; + } + }; + + 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-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-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..a092f513611 --- /dev/null +++ b/crates/bindings-typescript/src/server/http_handlers.ts @@ -0,0 +1,413 @@ +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; + } + 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}`; +} + +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..7f03100e37f --- /dev/null +++ b/crates/bindings-typescript/tests/http_handlers.test.ts @@ -0,0 +1,242 @@ +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('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()) + ).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/bindings/src/http.rs b/crates/bindings/src/http.rs index e31eda8dde4..a0ee89819ba 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -5,18 +5,397 @@ //! 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, + try_with_tx, with_tx, IterBuf, 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, http as st_http, TimeDuration}; +use spacetimedb_lib::{bsatn, Identity, TimeDuration, Uuid}; +use std::cell::{Cell, OnceCell}; +use std::str::FromStr; pub type Request = http::Request; pub type Response = http::Response; +/// 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 shadow 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 shadowed. +/// # } +/// ``` +#[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. +/// +/// 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 { + 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 { + try_with_tx(body) + } + + /// 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`], +/// 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 + } +} + +/// 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. +/// +/// ## 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, +} + +#[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::default() + } + + /// 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` 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) + } + + /// 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` 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) + } + + /// 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` 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) + } + + /// 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` 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) + } + + /// 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` 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) + } + + /// 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` 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) + } + + /// 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` 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) + } + + /// 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` is [invalid](Self#paths). + 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` is [invalid](Self#paths). + 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.is_empty() && !path.starts_with('/') { + panic!("Route paths must start with `/`: {path}"); + } + if !path.chars().all(character_is_acceptable_for_route_path) { + panic!( + "Route paths may contain only {}: {path}", + ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION + ); + } +} + +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) @@ -44,8 +423,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) { @@ -54,7 +433,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"); /// @@ -199,6 +578,80 @@ fn convert_response(response: st_http::Response) -> http::Result http::Request { + 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::Response, Bytes) { + 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(), + }; + + // 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. pub struct Body { inner: BodyInner, @@ -330,3 +783,63 @@ 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/lib.rs b/crates/bindings/src/lib.rs index 12195738971..abc972748dd 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -1183,6 +1183,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. @@ -1313,11 +1368,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 @@ -1351,61 +1402,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. diff --git a/crates/bindings/src/rng.rs b/crates/bindings/src/rng.rs index 4066abfeb20..d9ed0bf65d7 100644 --- a/crates/bindings/src/rng.rs +++ b/crates/bindings/src/rng.rs @@ -1,5 +1,5 @@ #[cfg(feature = "unstable")] -use crate::ProcedureContext; +use crate::{http::HandlerContext, ProcedureContext}; use crate::{rand, ReducerContext}; use core::cell::UnsafeCell; use core::marker::PhantomData; @@ -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..79543f4a925 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::{ProcedureContext, ProcedureResult}; +use crate::{ + http::{self, HandlerContext}, + ProcedureContext, ProcedureResult, +}; pub trait IntoVec { fn into_vec(self) -> Vec; @@ -287,6 +290,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 +865,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 +937,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 +959,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 +1004,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 +1179,60 @@ extern "C" fn __call_procedure__( 0 } +/// 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, + 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); + // 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_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 +} + /// Called by the host to execute an anonymous view. /// /// The `args` is a `BytesSource`, registered on the host side, 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..8b0f5f470cc --- /dev/null +++ b/crates/bindings/tests/pass/http_handler_no_style_warnings.rs @@ -0,0 +1,10 @@ +#![deny(warnings)] + +use spacetimedb::http::{HandlerContext, Request, Response}; + +#[spacetimedb::http::handler] +fn lowercase_handler(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::new(().into()) +} + +fn main() {} 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..b4edfa0f87b --- /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::{HandlerContext, Request, Response}; + +#[spacetimedb::http::handler] +fn index(_ctx: &mut 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..be6ee711699 --- /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::{HandlerContext, Request, Response}; + +#[spacetimedb::http::handler] +fn name(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::new(().into()) +} + +#[spacetimedb::table(accessor = things)] +struct Thing { + value: u32, +} + +fn main() {} 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] 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"); } diff --git a/crates/bindings/tests/ui/http_handlers.rs b/crates/bindings/tests/ui/http_handlers.rs new file mode 100644 index 00000000000..d05cd9d8114 --- /dev/null +++ b/crates/bindings/tests/ui/http_handlers.rs @@ -0,0 +1,68 @@ +use spacetimedb::http::{handler, router, HandlerContext, Request, Response, Router}; +use spacetimedb::{table, 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..960b3cdeae9 --- /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:77 + | +1 | use spacetimedb::http::{handler, router, HandlerContext, 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/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 e1464814656..60576cf752b 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -16,7 +16,7 @@ use crate::{ NodeDelegate, Unauthorized, }; use axum::body::{Body, Bytes}; -use axum::extract::{Path, Query, State}; +use axum::extract::{OriginalUri, Path, Query, Request, State}; use axum::response::{ErrorResponse, IntoResponse}; use axum::routing::MethodRouter; use axum::Extension; @@ -24,6 +24,7 @@ use axum_extra::TypedHeader; use derive_more::From; use futures::TryStreamExt; use http::StatusCode; +use http_body_util::BodyExt; use log::{info, warn}; use serde::Deserialize; use spacetimedb::auth::identity::ConnectionAuthCtx; @@ -42,6 +43,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, @@ -222,6 +224,138 @@ 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, + OriginalUri(original_uri): OriginalUri, + request: Request, +) -> axum::response::Result { + handle_http_route_impl(worker_ctx, name_or_identity, "".to_string(), 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, "/".to_string(), 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, format!("/{path}"), original_uri, 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, + handler_path: String, + original_uri: http::Uri, + request: Request, +) -> axum::response::Result { + 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, 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::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, body).await { + Ok(response) => response, + Err(spacetimedb::host::module_host::HttpHandlerCallError::NoSuchHandler) => { + return Ok((StatusCode::NOT_FOUND, NO_SUCH_ROUTE).into_response()); + } + 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.0, response.1)?; + 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()) @@ -230,6 +364,62 @@ 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::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, + 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, @@ -1308,6 +1498,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 @@ -1315,7 +1511,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::), @@ -1333,6 +1529,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::), } } } @@ -1359,9 +1558,316 @@ where .route("/pre_publish", self.pre_publish) .route("/reset", self.db_reset); - axum::Router::new() - .route("/", self.root_post) + 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::)) + .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", 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) + .merge(authed_named_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 { + async fn authorize_action( + &self, + _subject: Identity, + _database: Identity, + _action: Action, + ) -> Result<(), Unauthorized> { + Err(Unauthorized::InternalError(anyhow::anyhow!("unused"))) + } + + async fn authorize_sql(&self, _subject: Identity, _database: Identity) -> Result { + 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/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/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 0ad24bbfe01..1a8cf3257f9 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -56,10 +56,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::{Request as HttpRequest, Response as HttpResponse}; use spacetimedb_lib::identity::{AuthCtx, RequestId}; use spacetimedb_lib::metrics::ExecutionMetrics; use spacetimedb_lib::{bsatn, ConnectionId, TimeDuration, 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}; @@ -1117,6 +1118,13 @@ impl CallProcedureParams { } } +pub struct CallHttpHandlerParams { + pub timestamp: Timestamp, + pub handler_id: HttpHandlerId, + pub request: HttpRequest, + pub request_body: Bytes, +} + /// Holds a [`Module`] and a set of [`Instance`]s from it, /// and allocates the [`Instance`]s to be used for function calls. /// @@ -1534,6 +1542,16 @@ pub enum ProcedureCallError { InternalError(String), } +#[derive(thiserror::Error, Debug)] +pub enum HttpHandlerCallError { + #[error(transparent)] + NoSuchModule(#[from] NoSuchModule), + #[error("no such http handler")] + NoSuchHandler, + #[error("The module instance encountered a fatal error: {0}")] + InternalError(String), +} + #[derive(thiserror::Error, Debug)] pub enum InitDatabaseError { #[error(transparent)] @@ -2748,6 +2766,32 @@ impl ModuleHost { ) } + pub async fn call_http_handler( + &self, + handler_id: HttpHandlerId, + 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); + } + + let params = CallHttpHandlerParams { + timestamp: Timestamp::now(), + handler_id, + request, + request_body, + }; + + call_pooled_instance!( + self, + "http handler", + params, + |params, inst| inst.call_http_handler(params).await, + |params, inst| inst.call_http_handler(params).await, + )? + } + pub(super) async fn call_scheduled_reducer( &self, params: ScheduledFunctionParams, diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index 67b54cd2103..433fe5e2af8 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,15 +75,15 @@ 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, ExecutionError, ExecutionResult, ExecutionStats, ExecutionTimings, InstanceCommon, - InstanceOp, ProcedureExecuteResult, ProcedureOp, ReducerExecuteResult, ReducerOp, ViewExecuteResult, ViewOp, - WasmInstance, + AnonymousViewOp, DescribeError, 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}; @@ -710,6 +711,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 @@ -843,7 +854,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 { @@ -881,6 +892,11 @@ enum JsProcedureWorkerRequest { reply_tx: JsReplyTx, params: ScheduledFunctionParams, }, + /// See [`JsInstance::call_http_handler`]. + CallHttpHandler { + reply_tx: JsReplyTx>, + params: CallHttpHandlerParams, + }, } static_assert_size!(CallReducerParams, 192); @@ -1506,6 +1522,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 @@ -1889,6 +1914,23 @@ 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 = 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) + } } #[derive(thiserror::Error, Debug)] 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/core/src/host/wasm_common.rs b/crates/core/src/host/wasm_common.rs index 1328b3fa583..1e7fd18b5e1 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 8751a26804d..2fb5eab0492 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,10 @@ 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, - SqlCommand, SqlCommandResult, ViewCallResult, ViewCommand, ViewCommandResult, ViewOutcome, + call_identity_connected, init_database, CallHttpHandlerParams, CallProcedureParams, CallReducerParams, + CallViewParams, ClientConnectedError, DatabaseUpdate, EventStatus, HttpHandlerCallError, ModuleEvent, + ModuleFunctionCall, ModuleInfo, RefInstance, SqlCommand, SqlCommandResult, ViewCallResult, ViewCommand, + ViewCommandResult, ViewOutcome, }; use crate::host::scheduler::{CallScheduledFunctionResult, ScheduledFunctionParams}; use crate::host::{ @@ -45,8 +46,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}; @@ -98,6 +99,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 { @@ -313,6 +320,8 @@ pub type ViewExecuteResult = ExecutionResult; pub type ProcedureExecuteResult = ExecutionResult; +pub type HttpHandlerExecuteResult = ExecutionResult<(Bytes, Bytes), anyhow::Error>; + pub struct WasmModuleHostActor { module: T::InstancePre, common: ModuleCommon, @@ -537,6 +546,15 @@ impl WasmModuleInstance { res } + pub async fn call_http_handler( + &mut self, + params: CallHttpHandlerParams, + ) -> Result<(st_http::Response, Bytes), HttpHandlerCallError> { + 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_procedure( &mut self, params: ScheduledFunctionParams, @@ -828,6 +846,88 @@ impl InstanceCommon { (CallProcedureReturn { result, tx_offset }, trapped) } + pub(crate) async fn call_http_handler( + &mut self, + params: CallHttpHandlerParams, + inst: &mut I, + ) -> (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 { + 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, + request_body_bytes: request_body, + }; + + 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((response_bytes, response_body)) => bsatn::from_slice::(&response_bytes[..]) + .map(|response| (response, response_body)) + .map_err(|err| HttpHandlerCallError::InternalError(format!("{err}"))), + }; + + (result, trapped) + } + /// Execute a reducer. /// /// If `Some` [`MutTxId`] is supplied, the reducer is called within the @@ -1803,6 +1903,28 @@ 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, + pub request_body_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/wasm_instance_env.rs b/crates/core/src/host/wasmtime/wasm_instance_env.rs index 63eb862cddb..23113f1ab6c 100644 --- a/crates/core/src/host/wasmtime/wasm_instance_env.rs +++ b/crates/core/src/host/wasmtime/wasm_instance_env.rs @@ -115,8 +115,11 @@ 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>, + /// `File`-like byte sinks which guest code can write via [`Self::bytes_sink_write`]. + bytes_sinks: IntMap>, + + /// Counter as a source of sink IDs. + next_bytes_sink_id: NonZeroU32, /// The slab of `BufferIters` created for this instance. iters: RowIters, @@ -135,8 +138,6 @@ pub(super) struct WasmInstanceEnv { chunk_pool: ChunkPool, } -const STANDARD_BYTES_SINK: u32 = 1; - type WasmResult = Result; type RtResult = anyhow::Result; @@ -153,7 +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(1).unwrap(), iters: Default::default(), timing_spans: Default::default(), call_times: CallTimes::new(), @@ -169,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. @@ -195,6 +206,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:?}"); @@ -232,16 +247,14 @@ 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 + pub fn create_bytes_sink(&mut self) -> u32 { + let id = self.alloc_bytes_sink_id(); + self.bytes_sinks.insert(id, Vec::new()); + id } - /// 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 take_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. @@ -258,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, @@ -284,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`? @@ -303,8 +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(1).unwrap(); - (timings, self.take_standard_bytes_sink()) + (timings, result_bytes) } /// After a procedure has finished, take its known last tx offset, if any. @@ -1376,8 +1392,7 @@ 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 { + let Some(sink) = env.bytes_sinks.get_mut(&sink) else { return Ok(errno::NO_SUCH_BYTES.get().into()); }; @@ -1752,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(); @@ -1795,10 +1806,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 e5835331cc2..c89131fa08b 100644 --- a/crates/core/src/host/wasmtime/wasmtime_module.rs +++ b/crates/core/src/host/wasmtime/wasmtime_module.rs @@ -377,9 +377,9 @@ fn instantiate_wasmtime_instance( } 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, supports_async); - 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) @@ -404,6 +404,7 @@ fn instantiate_wasmtime_instance( store .data_mut() .set_call_view_exports(call_view.clone(), call_view_anon.clone()); + let call_http_handler = get_call_http_handler(&mut store, &instance); Ok(WasmtimeInstance { store, @@ -412,6 +413,7 @@ fn instantiate_wasmtime_instance( call_procedure, call_view, call_view_anon, + call_http_handler, supports_async, }) } @@ -472,6 +474,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; @@ -536,6 +552,25 @@ 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 metadata + u32, + // byte source id for request body + u32, + // byte sink id for response metadata + u32, + // byte sink id for response body + u32, + ), + i32, +>; + pub struct WasmtimeInstance { store: Store, instance: Instance, @@ -543,6 +578,7 @@ pub struct WasmtimeInstance { call_procedure: Option, call_view: Option, call_view_anon: Option, + call_http_handler: Option, supports_async: bool, } @@ -555,14 +591,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, self.supports_async) })?; // 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)?; @@ -618,7 +654,7 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { self.supports_async, ); - 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) @@ -652,7 +688,7 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { self.supports_async, ); - 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) @@ -689,7 +725,7 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { self.supports_async, ); - 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) @@ -757,7 +793,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(|| { @@ -775,6 +811,74 @@ 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, 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_bytes_sink(); + + 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, + request_body_source.0, + response_sink, + response_body_sink, + ), + ) + .await; + + store.data_mut().terminate_dangling_anon_tx(); + + 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) + .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 }; + + let tx_offset = store.data_mut().take_procedure_tx_offset(); + + (res, tx_offset) + } } fn set_store_fuel(store: &mut impl AsContextMut, fuel: WasmtimeFuel) { @@ -824,11 +928,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(); @@ -845,6 +953,17 @@ fn finish_opcall(store: &mut Store, initial_budget: FunctionBud (stats, ret_bytes) } +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_bytes_sink(response_body_sink); + let (stats, response_bytes) = finish_opcall(store, initial_budget, response_sink); + (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/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..137c3a9c306 100644 --- a/crates/lib/src/http.rs +++ b/crates/lib/src/http.rs @@ -45,7 +45,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 +165,19 @@ impl Response { self.headers.size_in_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/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..25ff3e3e9cc 100644 --- a/crates/schema/Cargo.toml +++ b/crates/schema/Cargo.toml @@ -16,7 +16,6 @@ spacetimedb-sats = { workspace = true, features = ["memory-usage"] } spacetimedb-data-structures.workspace = true spacetimedb-memory-usage.workspace = true spacetimedb-sql-parser.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 e840770a8c2..5ea6370f2d0 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -1,8 +1,10 @@ +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::{ @@ -13,7 +15,6 @@ use crate::def::*; use crate::error::ValidationError; use crate::type_for_generate::ProductTypeDef; use crate::{def::validate::Result, error::TypeLocation}; - // Utitility struct to look up canonical names for tables, functions, and indexes based on the // explicit names provided in the `RawModuleDefV10`. #[derive(Default)] @@ -136,6 +137,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 +234,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 +256,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 +273,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,8 +290,14 @@ pub fn validate(def: RawModuleDefV10) -> Result { .map(|rls| (rls.sql.clone(), rls.to_owned())) .collect(); - let (tables, types, reducers, procedures, views) = - (tables_types_reducers_procedures_views).map_err(|errors| errors.sort_deduplicate())?; + 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))| { + (tables, types, reducers, procedures, views, http_handlers, http_routes) + }, + ) + .map_err(|errors: ValidationErrors| errors.sort_deduplicate())?; let typespace_for_generate = typespace_for_generate.finish(); @@ -280,6 +313,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 +363,53 @@ fn change_scheduled_functions_and_lifetimes_visibility( Ok(()) } +fn validate_http_route_path(path: &RawIdentifier) -> Result<()> { + let path_str = path.as_ref(); + 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(()) +} + +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>, } @@ -674,6 +756,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, @@ -882,9 +1004,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}; @@ -1650,6 +1773,155 @@ 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"); + 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(); + + 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] + 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 ec1c2f35919..618f8e3c9c4 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..33abb0c1866 100644 --- a/crates/schema/src/error.rs +++ b/crates/schema/src/error.rs @@ -1,5 +1,7 @@ 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::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; @@ -135,6 +137,17 @@ 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; 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 }, #[error("lifecycle event {lifecycle:?} without reducer")] LifecycleWithoutReducer { lifecycle: Lifecycle }, #[error("lifecycle event {lifecycle:?} assigned multiple reducers")] 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/modules/Cargo.lock b/crates/smoketests/modules/Cargo.lock index 09e926aec32..4a12880dfd1 100644 --- a/crates/smoketests/modules/Cargo.lock +++ b/crates/smoketests/modules/Cargo.lock @@ -1000,6 +1000,7 @@ dependencies = [ "anyhow", "bitflags", "blake3", + "bytes", "chrono", "derive_more", "enum-as-inner", 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 new file mode 100644 index 00000000000..567c3684ac8 --- /dev/null +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -0,0 +1,1586 @@ +use regex::Regex; +use spacetimedb_smoketests::{require_dotnet, require_emscripten, require_pnpm, workspace_root, Smoketest}; +use std::{fs, path::Path}; + +const MODULE_CODE: &str = r#" +use spacetimedb::http::{Body, HandlerContext, Request, Response, Router}; +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 EXAMPLE_MODULE_CODE: &str = r#" +use std::str::FromStr; + +use spacetimedb::http::{Body, HandlerContext, Request, Response, Router}; +use spacetimedb::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 STRICT_ROOT_ROUTING_MODULE_CODE: &str = r#" +use spacetimedb::http::{Body, HandlerContext, Request, Response, Router}; + +#[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, HandlerContext, Request, Response, Router}; + +#[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 FULL_URI_MODULE_CODE: &str = r#" +use spacetimedb::http::{Body, HandlerContext, Request, Response, Router}; + +#[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 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 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( + { 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; +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_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(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()) + .collect(); + + assert!( + !blocks.is_empty(), + "expected at least one {} code block in {}", + language_name, + doc_path.display() + ); + + blocks.join("\n\n") +} + +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 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(); + 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(); + (test, identity) +} + +fn route_base(server_url: &str, identity: &str) -> String { + format!("{server_url}/v1/database/{identity}/route") +} + +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"); + 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!("{server_url}/v1/database/{identity}/schema?version=10")) + .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()); +} + +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(); + + 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()); +} + +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"); + 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); +} + +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"); + 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"); +} + +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(); + + let resp = client.get(&url).send().expect("echo-uri failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("echo-uri body"), url); +} + +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 + .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" + ); +} + +#[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 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); + assert_http_routes_end_to_end(&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 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); + assert_http_routes_pr_example_round_trip(&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 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( + "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!(); + 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 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) = + 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!(); + 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 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); + assert_http_handler_observes_full_external_uri(&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 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( + "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!(); + 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_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"); + + 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!"); +} + +/// 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() { + 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() { + 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/crates/smoketests/tests/smoketests/mod.rs b/crates/smoketests/tests/smoketests/mod.rs index ce26d29d09f..959d43eab8f 100644 --- a/crates/smoketests/tests/smoketests/mod.rs +++ b/crates/smoketests/tests/smoketests/mod.rs @@ -20,6 +20,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; 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 ec13f702bca..5149aa7e6c3 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/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..c8bc481d338 --- /dev/null +++ b/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md @@ -0,0 +1,228 @@ +--- +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 + + + + +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`: + +```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!")) +} +``` + + + + +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!"), + }; +} +``` + + + + +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!") + ); + } +} +``` + + + + +## 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 `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]`. + +```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. + + + + +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. + + + + +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. + + + + +### 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. 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 d39f4c77f80..e6137bea0df 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/00600-HTTP-handlers.md). diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index cd8cbfd0111..3ba1b433ba3 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -490,12 +490,28 @@ fn main() -> Result<()> { "spacetimedb-smoketests", "--exclude", "spacetimedb-sdk", + "--exclude", + "spacetimedb", "--", "--test-threads=2", "--skip", "unreal" ) .run()?; + // 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", + "-p", + "spacetimedb", + "--features", + "unstable", + "--", + "--test-threads=2", + ) + .run()?; // SDK procedure tests intentionally make localhost HTTP requests. cmd!( "cargo",