From cf032c54efd31a0914272cd7b5acae5b0e4f8e10 Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Fri, 1 May 2026 09:40:42 -0700 Subject: [PATCH 1/7] Initial addition of http-handlers for C++ mirrored from Rust --- crates/bindings-cpp/include/spacetimedb.h | 3 + .../include/spacetimedb/handler_context.h | 88 ++++++++ .../include/spacetimedb/http_client_impl.h | 2 +- .../include/spacetimedb/http_convert.h | 20 ++ .../include/spacetimedb/http_handler_macros.h | 57 +++++ .../include/spacetimedb/http_wire.h | 64 ++++++ .../include/spacetimedb/internal/Module.h | 7 + .../internal/autogen/MethodOrAny.g.h | 28 +++ .../internal/autogen/RawHttpHandlerDefV10.g.h | 26 +++ .../internal/autogen/RawHttpRouteDefV10.g.h | 31 +++ .../autogen/RawModuleDefV10Section.g.h | 4 +- .../internal/runtime_registration.h | 8 + .../spacetimedb/internal/tx_execution.h | 150 +++++++++++++ .../spacetimedb/internal/v10_builder.h | 13 ++ .../include/spacetimedb/procedure_context.h | 102 ++------- .../include/spacetimedb/reducer_context.h | 3 + .../bindings-cpp/include/spacetimedb/router.h | 186 ++++++++++++++++ .../bindings-cpp/src/abi/module_exports.cpp | 15 ++ crates/bindings-cpp/src/internal/Module.cpp | 66 ++++++ .../bindings-cpp/src/internal/v10_builder.cpp | 38 ++++ .../tests/compile/CMakeLists.module.txt | 48 ++++ crates/bindings-cpp/tests/compile/README.md | 64 ++++++ .../error_http_handler_immutable_ctx.cpp | 14 ++ .../error_http_handler_no_args.cpp | 12 + .../error_http_handler_no_connection_id.cpp | 14 ++ .../error_http_handler_no_db.cpp | 20 ++ .../error_http_handler_no_request_arg.cpp | 13 ++ .../error_http_handler_no_return_type.cpp | 12 + .../error_http_handler_no_sender.cpp | 14 ++ .../error_http_handler_wrong_ctx.cpp | 14 ++ ...or_http_handler_wrong_request_arg_type.cpp | 14 ++ .../error_http_handler_wrong_return_type.cpp | 9 + .../error_http_router_not_a_function.cpp | 5 + .../error_http_router_with_args.cpp | 19 ++ .../error_http_router_wrong_return_type.cpp | 7 + .../http-handlers/ok_http_handlers_basic.cpp | 30 +++ .../tests/compile/run-compile-tests.ps1 | 208 +++++++++++++++++ .../tests/compile/run-compile-tests.sh | 209 ++++++++++++++++++ 38 files changed, 1550 insertions(+), 87 deletions(-) create mode 100644 crates/bindings-cpp/include/spacetimedb/handler_context.h create mode 100644 crates/bindings-cpp/include/spacetimedb/http_handler_macros.h create mode 100644 crates/bindings-cpp/include/spacetimedb/internal/autogen/MethodOrAny.g.h create mode 100644 crates/bindings-cpp/include/spacetimedb/internal/autogen/RawHttpHandlerDefV10.g.h create mode 100644 crates/bindings-cpp/include/spacetimedb/internal/autogen/RawHttpRouteDefV10.g.h create mode 100644 crates/bindings-cpp/include/spacetimedb/internal/tx_execution.h create mode 100644 crates/bindings-cpp/include/spacetimedb/router.h create mode 100644 crates/bindings-cpp/tests/compile/CMakeLists.module.txt create mode 100644 crates/bindings-cpp/tests/compile/README.md create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_immutable_ctx.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_args.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_connection_id.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_db.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_request_arg.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_return_type.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_sender.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_ctx.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_request_arg_type.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_return_type.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_not_a_function.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_with_args.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_wrong_return_type.cpp create mode 100644 crates/bindings-cpp/tests/compile/cases/http-handlers/ok_http_handlers_basic.cpp create mode 100644 crates/bindings-cpp/tests/compile/run-compile-tests.ps1 create mode 100644 crates/bindings-cpp/tests/compile/run-compile-tests.sh diff --git a/crates/bindings-cpp/include/spacetimedb.h b/crates/bindings-cpp/include/spacetimedb.h index a36d96074a8..0eaaf9936cc 100644 --- a/crates/bindings-cpp/include/spacetimedb.h +++ b/crates/bindings-cpp/include/spacetimedb.h @@ -126,6 +126,9 @@ // Procedure context and macros #include "spacetimedb/procedure_macros.h" +#include "spacetimedb/handler_context.h" +#include "spacetimedb/router.h" +#include "spacetimedb/http_handler_macros.h" // ============================================================================= // VIEW SYSTEM 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..03a10eba0b5 --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/handler_context.h @@ -0,0 +1,88 @@ +#ifndef SPACETIMEDB_HANDLER_CONTEXT_H +#define SPACETIMEDB_HANDLER_CONTEXT_H + +#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_client_impl.h b/crates/bindings-cpp/include/spacetimedb/http_client_impl.h index ff3e0faf3df..374b60a406c 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 { diff --git a/crates/bindings-cpp/include/spacetimedb/http_convert.h b/crates/bindings-cpp/include/spacetimedb/http_convert.h index b479cf84d03..75f2652de15 100644 --- a/crates/bindings-cpp/include/spacetimedb/http_convert.h +++ b/crates/bindings-cpp/include/spacetimedb/http_convert.h @@ -268,6 +268,26 @@ inline HttpResponse from_wire(const wire::HttpResponse& response) { return result; } +inline wire::RequestAndBody to_wire_with_body(const HttpRequest& request) { + return wire::RequestAndBody{to_wire(request), request.body.bytes}; +} + +inline HttpRequest from_wire(const wire::RequestAndBody& request) { + HttpRequest result = from_wire(request.request); + result.body.bytes = request.body; + return result; +} + +inline wire::ResponseAndBody to_wire_with_body(const HttpResponse& response) { + return wire::ResponseAndBody{to_wire(response), response.body.bytes}; +} + +inline HttpResponse from_wire(const wire::ResponseAndBody& response) { + HttpResponse result = from_wire(response.response); + result.body.bytes = response.body; + return result; +} + } // namespace convert } // namespace SpacetimeDB 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..864b3ea4425 --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/http_handler_macros.h @@ -0,0 +1,57 @@ +#pragma once + +#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, reinterpret_cast(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..51871ab385e 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 * @@ -155,6 +163,16 @@ struct HttpResponse { uint16_t code; // Field 2: HTTP status code }; +struct RequestAndBody { + HttpRequest request; + std::vector body; +}; + +struct ResponseAndBody { + HttpResponse response; + std::vector body; +}; + } // namespace wire } // namespace SpacetimeDB @@ -169,6 +187,8 @@ template<> struct bsatn_traits; template<> struct bsatn_traits; template<> struct bsatn_traits; template<> struct bsatn_traits; +template<> struct bsatn_traits; +template<> struct bsatn_traits; // HttpMethod enum serialization template<> @@ -341,6 +361,50 @@ struct bsatn_traits { } }; +template<> +struct bsatn_traits { + static void serialize(Writer& writer, const wire::RequestAndBody& value) { + bsatn::serialize(writer, value.request); + bsatn::serialize(writer, value.body); + } + + static wire::RequestAndBody deserialize(Reader& reader) { + wire::RequestAndBody result; + result.request = bsatn::deserialize(reader); + result.body = bsatn::deserialize>(reader); + return result; + } + + static AlgebraicType algebraic_type() { + ProductTypeBuilder builder; + builder.with_field("request"); + builder.with_field>("body"); + return AlgebraicType::make_product(builder.build()); + } +}; + +template<> +struct bsatn_traits { + static void serialize(Writer& writer, const wire::ResponseAndBody& value) { + bsatn::serialize(writer, value.response); + bsatn::serialize(writer, value.body); + } + + static wire::ResponseAndBody deserialize(Reader& reader) { + wire::ResponseAndBody result; + result.response = bsatn::deserialize(reader); + result.body = bsatn::deserialize>(reader); + return result; + } + + static AlgebraicType algebraic_type() { + ProductTypeBuilder builder; + builder.with_field("response"); + builder.with_field>("body"); + return AlgebraicType::make_product(builder.build()); + } +}; + } // namespace SpacetimeDB::bsatn #endif // SPACETIMEDB_HTTP_WIRE_H diff --git a/crates/bindings-cpp/include/spacetimedb/internal/Module.h b/crates/bindings-cpp/include/spacetimedb/internal/Module.h index dd27c18dc3a..b669dffc36f 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/Module.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/Module.h @@ -75,6 +75,13 @@ class Module { BytesSource args_source, BytesSink result_sink ); + + static int16_t __call_http_handler__( + uint32_t id, + uint64_t timestamp_microseconds, + BytesSource request_source, + BytesSink result_sink + ); // Internal registration methods (inline to avoid linking issues) template 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..f266d538c3d --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/MethodOrAny.g.h @@ -0,0 +1,28 @@ +// 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 "spacetimedb/http_wire.h" + +namespace SpacetimeDB::Internal { + +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(MethodOrAny_Method_Wrapper) { + SpacetimeDB::wire::HttpMethod value; + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, value); + } + SPACETIMEDB_PRODUCT_TYPE_EQUALITY(value) +}; + +SPACETIMEDB_INTERNAL_TAGGED_ENUM(MethodOrAny, std::monostate, MethodOrAny_Method_Wrapper) +} // 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/RawModuleDefV10Section.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h index 241466f467c..5ddbde5e11a 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h @@ -23,8 +23,10 @@ #include "RawLifeCycleReducerDefV10.g.h" #include "RawRowLevelSecurityDefV9.g.h" #include "ExplicitNames.g.h" +#include "RawHttpHandlerDefV10.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/runtime_registration.h b/crates/bindings-cpp/include/spacetimedb/internal/runtime_registration.h index 4d84cb975d4..ee00ff1d54a 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/runtime_registration.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/runtime_registration.h @@ -14,6 +14,9 @@ struct ReducerContext; struct ViewContext; struct AnonymousViewContext; struct ProcedureContext; +struct HandlerContext; +struct HttpRequest; +struct HttpResponse; namespace Internal { @@ -26,9 +29,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, + const void* handler_symbol, + std::function handler); +std::string LookupHttpHandlerName(const void* 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..9f7bde5b34a --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/router.h @@ -0,0 +1,186 @@ +#ifndef SPACETIMEDB_ROUTER_H +#define SPACETIMEDB_ROUTER_H + +#include +#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; + } + const auto& a_method = a.method.template get<1>().value; + const auto& b_method = b.method.template get<1>().value; + return a_method == b_method; + } + + 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; + Internal::MethodOrAny_Method_Wrapper wrapper; + wrapper.value = convert::to_wire(method); + result.set<1>(wrapper); + 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) { + const void* symbol = reinterpret_cast(handler); + return Internal::LookupHttpHandlerName(symbol); + } +}; + +} // 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..473dc9e2fb1 100644 --- a/crates/bindings-cpp/src/abi/module_exports.cpp +++ b/crates/bindings-cpp/src/abi/module_exports.cpp @@ -99,4 +99,19 @@ extern "C" { ); } + STDB_EXPORT(__call_http_handler__) + int16_t __call_http_handler__( + uint32_t id, + uint64_t timestamp_microseconds, + SpacetimeDB::BytesSource request_source, + SpacetimeDB::BytesSink result_sink + ) { + return SpacetimeDB::Internal::Module::__call_http_handler__( + id, + timestamp_microseconds, + request_source, + result_sink + ); + } + } // extern "C" diff --git a/crates/bindings-cpp/src/internal/Module.cpp b/crates/bindings-cpp/src/internal/Module.cpp index b0dcb1ceae3..ebb73313160 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; + const void* 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, + const void* handler_symbol, + std::function handler) { + g_http_handlers.push_back({name, handler_symbol, handler}); + } + + std::string LookupHttpHandlerName(const void* 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,38 @@ 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, + BytesSink result_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::RequestAndBody wire_request = bsatn::deserialize(request_reader); + HttpRequest request = convert::from_wire(wire_request); + + HttpResponse response = g_http_handlers[id].handler(ctx, std::move(request)); + wire::ResponseAndBody wire_response = convert::to_wire_with_body(response); + + std::vector result_data; + { + bsatn::Writer writer(result_data); + bsatn::serialize(writer, wire_response); + } + WriteBytes(result_sink, result_data); + return 0; +} + void Module::SetCaseConversionPolicy(CaseConversionPolicy policy) { getV10Builder().SetCaseConversionPolicy(policy); } 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..5c264b8f0cb --- /dev/null +++ b/crates/bindings-cpp/tests/compile/CMakeLists.module.txt @@ -0,0 +1,48 @@ +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) + +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..1832f28496a --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_immutable_ctx.cpp @@ -0,0 +1,14 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_immutable_ctx, const HandlerContext& ctx, HttpRequest request) { + (void)ctx; + (void)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..cbebae53852 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_connection_id.cpp @@ -0,0 +1,14 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_no_connection_id, HandlerContext ctx, HttpRequest request) { + (void)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..707b2183be0 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_db.cpp @@ -0,0 +1,20 @@ +#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) { + (void)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..ac2eebad2d2 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_request_arg.cpp @@ -0,0 +1,13 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_no_request_arg, HandlerContext ctx) { + (void)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..cbc5cd4f29f --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_return_type.cpp @@ -0,0 +1,12 @@ +#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) { + (void)ctx; + (void)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..6d84d5e4af1 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_no_sender.cpp @@ -0,0 +1,14 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_no_sender, HandlerContext ctx, HttpRequest request) { + (void)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..f2637cfc53c --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_ctx.cpp @@ -0,0 +1,14 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_wrong_ctx, ProcedureContext ctx, HttpRequest request) { + (void)ctx; + (void)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..5c06d017dba --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_request_arg_type.cpp @@ -0,0 +1,14 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_wrong_request_arg_type, HandlerContext ctx, uint32_t request) { + (void)ctx; + (void)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..ca22aedef90 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_handler_wrong_return_type.cpp @@ -0,0 +1,9 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(handler_wrong_return_type, HandlerContext ctx, HttpRequest request) { + (void)ctx; + (void)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..173b0b7deca --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/error_http_router_with_args.cpp @@ -0,0 +1,19 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(hello_handler, HandlerContext ctx, HttpRequest request) { + (void)ctx; + (void)request; + return HttpResponse{ + 200, + HttpVersion::Http11, + {}, + HttpBody::from_string("ok"), + }; +} + +SPACETIMEDB_HTTP_ROUTER(register_http_routes, HandlerContext ctx) { + (void)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..2bcdd546426 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/cases/http-handlers/ok_http_handlers_basic.cpp @@ -0,0 +1,30 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(hello_handler, HandlerContext ctx, HttpRequest request) { + (void)ctx; + (void)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..889c44787c4 --- /dev/null +++ b/crates/bindings-cpp/tests/compile/run-compile-tests.ps1 @@ -0,0 +1,208 @@ +[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 { + & $FilePath @Arguments *> $LogPath + 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 + } +} + +$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" + + 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=$caseSource", + "-DOUTPUT_NAME=$($case.Name)", + "-DSPACETIMEDB_LIBRARY_DIR=$libraryBuildDir", + "-DSPACETIMEDB_INCLUDE_DIR=$includeDir" + ) -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." From 088c7ba070a137ef22dd684b06ecd689bb2ae2ab Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Mon, 11 May 2026 14:58:18 -0700 Subject: [PATCH 2/7] Matching to 5fab877 --- crates/bindings-cpp/CMakeLists.txt | 45 +-------------- .../include/spacetimedb/abi/abi.h | 31 ++++++++++ .../include/spacetimedb/http_convert.h | 26 +++------ .../include/spacetimedb/http_wire.h | 56 ------------------- .../include/spacetimedb/internal/Module.h | 4 +- .../bindings-cpp/src/abi/module_exports.cpp | 8 ++- crates/bindings-cpp/src/internal/Module.cpp | 17 +++--- crates/bindings-cpp/tests/unit/CMakeLists.txt | 34 +++++++++++ crates/bindings-cpp/tests/unit/README.md | 52 +++++++++++++++++ .../tests/unit/http_unit_tests.cpp | 56 +++++++++++++++++++ crates/bindings-cpp/tests/unit/main.cpp | 34 +++++++++++ .../tests/unit/run-unit-tests.ps1 | 52 +++++++++++++++++ .../bindings-cpp/tests/unit/run-unit-tests.sh | 55 ++++++++++++++++++ crates/bindings-cpp/tests/unit/test_harness.h | 49 ++++++++++++++++ 14 files changed, 392 insertions(+), 127 deletions(-) create mode 100644 crates/bindings-cpp/tests/unit/CMakeLists.txt create mode 100644 crates/bindings-cpp/tests/unit/README.md create mode 100644 crates/bindings-cpp/tests/unit/http_unit_tests.cpp create mode 100644 crates/bindings-cpp/tests/unit/main.cpp create mode 100644 crates/bindings-cpp/tests/unit/run-unit-tests.ps1 create mode 100644 crates/bindings-cpp/tests/unit/run-unit-tests.sh create mode 100644 crates/bindings-cpp/tests/unit/test_harness.h diff --git a/crates/bindings-cpp/CMakeLists.txt b/crates/bindings-cpp/CMakeLists.txt index 35a4e0a1701..4c00b43323d 100644 --- a/crates/bindings-cpp/CMakeLists.txt +++ b/crates/bindings-cpp/CMakeLists.txt @@ -60,46 +60,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/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/http_convert.h b/crates/bindings-cpp/include/spacetimedb/http_convert.h index 75f2652de15..7c02a1fb021 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,24 +274,8 @@ inline HttpResponse from_wire(const wire::HttpResponse& response) { return result; } -inline wire::RequestAndBody to_wire_with_body(const HttpRequest& request) { - return wire::RequestAndBody{to_wire(request), request.body.bytes}; -} - -inline HttpRequest from_wire(const wire::RequestAndBody& request) { - HttpRequest result = from_wire(request.request); - result.body.bytes = request.body; - return result; -} - -inline wire::ResponseAndBody to_wire_with_body(const HttpResponse& response) { - return wire::ResponseAndBody{to_wire(response), response.body.bytes}; -} - -inline HttpResponse from_wire(const wire::ResponseAndBody& response) { - HttpResponse result = from_wire(response.response); - result.body.bytes = response.body; - return result; +inline std::pair> to_wire_split(const HttpResponse& response) { + return {to_wire(response), response.body.bytes}; } } // namespace convert diff --git a/crates/bindings-cpp/include/spacetimedb/http_wire.h b/crates/bindings-cpp/include/spacetimedb/http_wire.h index 51871ab385e..ae473512a37 100644 --- a/crates/bindings-cpp/include/spacetimedb/http_wire.h +++ b/crates/bindings-cpp/include/spacetimedb/http_wire.h @@ -163,16 +163,6 @@ struct HttpResponse { uint16_t code; // Field 2: HTTP status code }; -struct RequestAndBody { - HttpRequest request; - std::vector body; -}; - -struct ResponseAndBody { - HttpResponse response; - std::vector body; -}; - } // namespace wire } // namespace SpacetimeDB @@ -187,8 +177,6 @@ template<> struct bsatn_traits; template<> struct bsatn_traits; template<> struct bsatn_traits; template<> struct bsatn_traits; -template<> struct bsatn_traits; -template<> struct bsatn_traits; // HttpMethod enum serialization template<> @@ -361,50 +349,6 @@ struct bsatn_traits { } }; -template<> -struct bsatn_traits { - static void serialize(Writer& writer, const wire::RequestAndBody& value) { - bsatn::serialize(writer, value.request); - bsatn::serialize(writer, value.body); - } - - static wire::RequestAndBody deserialize(Reader& reader) { - wire::RequestAndBody result; - result.request = bsatn::deserialize(reader); - result.body = bsatn::deserialize>(reader); - return result; - } - - static AlgebraicType algebraic_type() { - ProductTypeBuilder builder; - builder.with_field("request"); - builder.with_field>("body"); - return AlgebraicType::make_product(builder.build()); - } -}; - -template<> -struct bsatn_traits { - static void serialize(Writer& writer, const wire::ResponseAndBody& value) { - bsatn::serialize(writer, value.response); - bsatn::serialize(writer, value.body); - } - - static wire::ResponseAndBody deserialize(Reader& reader) { - wire::ResponseAndBody result; - result.response = bsatn::deserialize(reader); - result.body = bsatn::deserialize>(reader); - return result; - } - - static AlgebraicType algebraic_type() { - ProductTypeBuilder builder; - builder.with_field("response"); - builder.with_field>("body"); - return AlgebraicType::make_product(builder.build()); - } -}; - } // namespace SpacetimeDB::bsatn #endif // SPACETIMEDB_HTTP_WIRE_H diff --git a/crates/bindings-cpp/include/spacetimedb/internal/Module.h b/crates/bindings-cpp/include/spacetimedb/internal/Module.h index b669dffc36f..7e02e858fb6 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/Module.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/Module.h @@ -80,7 +80,9 @@ class Module { uint32_t id, uint64_t timestamp_microseconds, BytesSource request_source, - BytesSink result_sink + BytesSource request_body_source, + BytesSink response_sink, + BytesSink response_body_sink ); // Internal registration methods (inline to avoid linking issues) diff --git a/crates/bindings-cpp/src/abi/module_exports.cpp b/crates/bindings-cpp/src/abi/module_exports.cpp index 473dc9e2fb1..a31d50be1fb 100644 --- a/crates/bindings-cpp/src/abi/module_exports.cpp +++ b/crates/bindings-cpp/src/abi/module_exports.cpp @@ -104,13 +104,17 @@ extern "C" { uint32_t id, uint64_t timestamp_microseconds, SpacetimeDB::BytesSource request_source, - SpacetimeDB::BytesSink result_sink + 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, - result_sink + request_body_source, + response_sink, + response_body_sink ); } diff --git a/crates/bindings-cpp/src/internal/Module.cpp b/crates/bindings-cpp/src/internal/Module.cpp index ebb73313160..421c4968c5d 100644 --- a/crates/bindings-cpp/src/internal/Module.cpp +++ b/crates/bindings-cpp/src/internal/Module.cpp @@ -668,7 +668,9 @@ int16_t Module::__call_http_handler__( uint32_t id, uint64_t timestamp_microseconds, BytesSource request_source, - BytesSink result_sink + 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", @@ -681,18 +683,19 @@ int16_t Module::__call_http_handler__( std::vector request_bytes = ConsumeBytes(request_source); bsatn::Reader request_reader(request_bytes.data(), request_bytes.size()); - wire::RequestAndBody wire_request = bsatn::deserialize(request_reader); - HttpRequest request = convert::from_wire(wire_request); + 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)); - wire::ResponseAndBody wire_response = convert::to_wire_with_body(response); + auto [wire_response, response_body] = convert::to_wire_split(response); - std::vector result_data; + std::vector response_metadata; { - bsatn::Writer writer(result_data); + bsatn::Writer writer(response_metadata); bsatn::serialize(writer, wire_response); } - WriteBytes(result_sink, result_data); + WriteBytes(response_sink, response_metadata); + WriteBytes(response_body_sink, response_body); return 0; } diff --git a/crates/bindings-cpp/tests/unit/CMakeLists.txt b/crates/bindings-cpp/tests/unit/CMakeLists.txt new file mode 100644 index 00000000000..7a058e88fb9 --- /dev/null +++ b/crates/bindings-cpp/tests/unit/CMakeLists.txt @@ -0,0 +1,34 @@ +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 +) + +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 From 272cd574ee2b683543249da397154bc8762a6cad Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Tue, 12 May 2026 15:55:00 -0700 Subject: [PATCH 3/7] Fix unstable flag, add smoketests and update docs --- crates/bindings-cpp/CMakeLists.txt | 1 + crates/bindings-cpp/include/spacetimedb.h | 2 + .../include/spacetimedb/handler_context.h | 4 + .../bindings-cpp/include/spacetimedb/http.h | 4 + .../include/spacetimedb/http_handler_macros.h | 4 + .../bindings-cpp/include/spacetimedb/router.h | 4 + .../tests/compile/CMakeLists.module.txt | 1 + .../tests/compile/run-compile-tests.ps1 | 21 +- .../type-isolation-test/CMakeLists.module.txt | 3 +- crates/bindings-cpp/tests/unit/CMakeLists.txt | 4 + crates/smoketests/src/lib.rs | 107 +++- .../tests/smoketests/http_routes.rs | 571 ++++++++++++++++-- .../00200-functions/00600-HTTP-handlers.md | 92 +++ 13 files changed, 761 insertions(+), 57 deletions(-) diff --git a/crates/bindings-cpp/CMakeLists.txt b/crates/bindings-cpp/CMakeLists.txt index 4c00b43323d..a66b50ad9e2 100644 --- a/crates/bindings-cpp/CMakeLists.txt +++ b/crates/bindings-cpp/CMakeLists.txt @@ -30,6 +30,7 @@ target_sources(spacetimedb_cpp_library PRIVATE ${LIBRARY_SOURCES}) # Require C++20 for consumers of this library without forcing global flags target_compile_features(spacetimedb_cpp_library PUBLIC cxx_std_20) +target_compile_definitions(spacetimedb_cpp_library PRIVATE SPACETIMEDB_UNSTABLE_FEATURES) # Set include directories target_include_directories(spacetimedb_cpp_library diff --git a/crates/bindings-cpp/include/spacetimedb.h b/crates/bindings-cpp/include/spacetimedb.h index 0eaaf9936cc..4ed389d06fa 100644 --- a/crates/bindings-cpp/include/spacetimedb.h +++ b/crates/bindings-cpp/include/spacetimedb.h @@ -126,9 +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/handler_context.h b/crates/bindings-cpp/include/spacetimedb/handler_context.h index 03a10eba0b5..cb9b4abe84c 100644 --- a/crates/bindings-cpp/include/spacetimedb/handler_context.h +++ b/crates/bindings-cpp/include/spacetimedb/handler_context.h @@ -1,6 +1,10 @@ #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 diff --git a/crates/bindings-cpp/include/spacetimedb/http.h b/crates/bindings-cpp/include/spacetimedb/http.h index cb51acafa0a..f24c76a8128 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 diff --git a/crates/bindings-cpp/include/spacetimedb/http_handler_macros.h b/crates/bindings-cpp/include/spacetimedb/http_handler_macros.h index 864b3ea4425..df9a1fc3a18 100644 --- a/crates/bindings-cpp/include/spacetimedb/http_handler_macros.h +++ b/crates/bindings-cpp/include/spacetimedb/http_handler_macros.h @@ -1,5 +1,9 @@ #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" diff --git a/crates/bindings-cpp/include/spacetimedb/router.h b/crates/bindings-cpp/include/spacetimedb/router.h index 9f7bde5b34a..8c3f3b5191c 100644 --- a/crates/bindings-cpp/include/spacetimedb/router.h +++ b/crates/bindings-cpp/include/spacetimedb/router.h @@ -1,6 +1,10 @@ #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 diff --git a/crates/bindings-cpp/tests/compile/CMakeLists.module.txt b/crates/bindings-cpp/tests/compile/CMakeLists.module.txt index 5c264b8f0cb..63d00912c90 100644 --- a/crates/bindings-cpp/tests/compile/CMakeLists.module.txt +++ b/crates/bindings-cpp/tests/compile/CMakeLists.module.txt @@ -24,6 +24,7 @@ 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__']") diff --git a/crates/bindings-cpp/tests/compile/run-compile-tests.ps1 b/crates/bindings-cpp/tests/compile/run-compile-tests.ps1 index 889c44787c4..2dd8a40eaa4 100644 --- a/crates/bindings-cpp/tests/compile/run-compile-tests.ps1 +++ b/crates/bindings-cpp/tests/compile/run-compile-tests.ps1 @@ -35,7 +35,12 @@ function Invoke-LoggedCommand { } try { - & $FilePath @Arguments *> $LogPath + $quotedParts = @($FilePath) + $Arguments | ForEach-Object { + '"' + ($_ -replace '"', '\"') + '"' + } + $commandLine = ($quotedParts -join ' ') + " > `"$LogPath`" 2>&1" + + cmd /c $commandLine | Out-Null return $LASTEXITCODE } finally { if ($WorkingDirectory) { @@ -61,6 +66,11 @@ function New-CompileCase { } } +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)) @@ -126,6 +136,9 @@ foreach ($case in $cases) { $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 @@ -139,10 +152,10 @@ foreach ($case in $cases) { "cmake", "-S", $caseBuildDir, "-B", $caseBuildDir, - "-DMODULE_SOURCE=$caseSource", + "-DMODULE_SOURCE=$caseSourceCMake", "-DOUTPUT_NAME=$($case.Name)", - "-DSPACETIMEDB_LIBRARY_DIR=$libraryBuildDir", - "-DSPACETIMEDB_INCLUDE_DIR=$includeDir" + "-DSPACETIMEDB_LIBRARY_DIR=$libraryBuildDirCMake", + "-DSPACETIMEDB_INCLUDE_DIR=$includeDirCMake" ) -LogPath $configureLog -WorkingDirectory $scriptDir $buildExit = 0 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 index 7a058e88fb9..0ced4e0194c 100644 --- a/crates/bindings-cpp/tests/unit/CMakeLists.txt +++ b/crates/bindings-cpp/tests/unit/CMakeLists.txt @@ -17,6 +17,10 @@ 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() diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 1ef17047d31..088b51611db 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -353,6 +353,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). @@ -929,12 +986,50 @@ impl Smoketest { ])?; csharp::verify_csharp_module_restore(&module_path)?; - let re = Regex::new(r"identity: ([0-9a-fA-F]+)").unwrap(); - let identity = re - .captures(&publish_output) - .and_then(|caps| caps.get(1)) - .map(|m| m.as_str().to_string()) - .context("Failed to parse database identity from publish output")?; + let identity = parse_identity_from_publish_output(&publish_output)?; + self.database_identity = Some(identity.clone()); + + Ok(identity) + } + + /// Writes and publishes a C++ module from source. + /// + /// The module is created at `/`. + /// On success this updates `self.database_identity`. + pub fn publish_cpp_module_source( + &mut self, + project_dir_name: &str, + module_name: &str, + module_source: &str, + ) -> Result { + let module_path = self.project_dir.path().join(project_dir_name); + let src_dir = module_path.join("src"); + fs::create_dir_all(&src_dir).context("Failed to create C++ source directory")?; + + let bindings_cpp_path = workspace_root() + .join("crates/bindings-cpp") + .display() + .to_string() + .replace('\\', "/"); + let cmakelists = CPP_SMOKETEST_CMAKELISTS.replace("@SPACETIMEDB_CPP_LIBRARY_PATH@", &bindings_cpp_path); + + fs::write(module_path.join("CMakeLists.txt"), cmakelists) + .context("Failed to write C++ CMakeLists.txt")?; + fs::write(src_dir.join("lib.cpp"), module_source).context("Failed to write C++ module code")?; + + let module_path_str = module_path.to_str().context("Invalid C++ module path")?; + let publish_output = self.spacetime(&[ + "publish", + "--server", + &self.server_url, + "--module-path", + module_path_str, + "--yes", + "--clear-database", + module_name, + ])?; + + let identity = parse_identity_from_publish_output(&publish_output)?; self.database_identity = Some(identity.clone()); Ok(identity) diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 40abde11f8e..7c14f82ad2c 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -1,5 +1,5 @@ use regex::Regex; -use spacetimedb_smoketests::{workspace_root, Smoketest}; +use spacetimedb_smoketests::{require_emscripten, workspace_root, Smoketest}; use std::{fs, path::Path}; const MODULE_CODE: &str = r#" @@ -230,6 +230,363 @@ fn router() -> Router { } "#; +const CPP_MODULE_CODE: &str = r#"#include "spacetimedb.h" + +using namespace SpacetimeDB; + +struct Entry { + uint64_t id; + std::string value; +}; +SPACETIMEDB_STRUCT(Entry, id, value) +SPACETIMEDB_TABLE(Entry, entry, Public) + +namespace { + +std::string header_value_utf8(const HttpRequest& request, const std::string& header_name) { + for (const auto& header : request.headers) { + if (header.name == header_name) { + return std::string(header.value.begin(), header.value.end()); + } + } + return ""; +} + +HttpResponse text_response(uint16_t status_code, std::string body) { + return HttpResponse{ + status_code, + HttpVersion::Http11, + { HttpHeader{"content-type", "text/plain; charset=utf-8"} }, + HttpBody::from_string(body), + }; +} + +} // namespace + +SPACETIMEDB_HTTP_HANDLER(get_simple, HandlerContext ctx, HttpRequest request) { + (void)ctx; + (void)request; + return text_response(200, "ok"); +} + +SPACETIMEDB_HTTP_HANDLER(post_insert, HandlerContext ctx, HttpRequest request) { + (void)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) { + (void)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) { + (void)ctx; + (void)request; + return text_response(200, "any"); +} + +SPACETIMEDB_HTTP_HANDLER(header_echo, HandlerContext ctx, HttpRequest request) { + (void)ctx; + return text_response(200, header_value_utf8(request, "x-echo")); +} + +SPACETIMEDB_HTTP_HANDLER(set_response_header, HandlerContext ctx, HttpRequest request) { + (void)ctx; + (void)request; + return HttpResponse{ + 200, + HttpVersion::Http11, + { HttpHeader{"x-response", "set"} }, + HttpBody::from_string("header-set"), + }; +} + +SPACETIMEDB_HTTP_HANDLER(body_handler, HandlerContext ctx, HttpRequest request) { + (void)ctx; + (void)request; + return text_response(200, "non-empty"); +} + +SPACETIMEDB_HTTP_HANDLER(teapot, HandlerContext ctx, HttpRequest request) { + (void)ctx; + (void)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) { + (void)ctx; + (void)request; + return text_response("empty"); +} + +SPACETIMEDB_HTTP_HANDLER(slash_root, HandlerContext ctx, HttpRequest request) { + (void)ctx; + (void)request; + return text_response("slash"); +} + +SPACETIMEDB_HTTP_HANDLER(foo, HandlerContext ctx, HttpRequest request) { + (void)ctx; + (void)request; + return text_response("foo"); +} + +SPACETIMEDB_HTTP_HANDLER(foo_slash, HandlerContext ctx, HttpRequest request) { + (void)ctx; + (void)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) { + (void)ctx; + (void)request; + return text_response("foo"); +} + +SPACETIMEDB_HTTP_HANDLER(foo_slash, HandlerContext ctx, HttpRequest request) { + (void)ctx; + (void)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) { + (void)ctx; + 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) { + (void)ctx; + 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) { + (void)ctx; + 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 NO_SUCH_ROUTE_BODY: &str = "Database has not registered a handler for this route"; fn extract_rust_code_blocks(doc_path: &Path) -> String { @@ -251,12 +608,46 @@ fn extract_rust_code_blocks(doc_path: &Path) -> String { blocks.join("\n\n") } -#[test] -fn http_routes_end_to_end() { - let test = Smoketest::builder().module_code(MODULE_CODE).build(); - let identity = test.database_identity.as_ref().expect("database identity missing"); +fn extract_cpp_code_blocks(doc_path: &Path) -> String { + let doc = fs::read_to_string(doc_path).unwrap_or_else(|e| panic!("failed to read {}: {e}", doc_path.display())); + let doc = doc.replace("\r\n", "\n"); - let base = format!("{}/v1/database/{}/route", test.server_url, identity); + let re = Regex::new(r"```(?:cpp|c\+\+)\n([\s\S]*?)\n```").expect("regex should compile"); + let blocks: Vec<_> = re + .captures_iter(&doc) + .map(|cap| cap.get(1).expect("capture group should exist").as_str().to_string()) + .collect(); + + assert!( + !blocks.is_empty(), + "expected at least one cpp code block in {}", + 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(module_name: &str, db_name: &str, module_code: &str) -> (Smoketest, String) { + require_emscripten!(); + let mut test = Smoketest::builder().autopublish(false).build(); + let identity = test + .publish_cpp_module_source(module_name, db_name, module_code) + .unwrap(); + (test, identity) +} + +fn assert_http_routes_end_to_end(server_url: &str, identity: &str) { + let base = format!("{server_url}/v1/database/{identity}/route"); let client = reqwest::blocking::Client::new(); let resp = client.get(format!("{base}/get")).send().expect("get failed"); @@ -311,10 +702,7 @@ fn http_routes_end_to_end() { assert_eq!(resp.text().expect("missing route body"), NO_SUCH_ROUTE_BODY); let resp = client - .get(format!( - "{}/v1/database/{}/schema?version=10", - test.server_url, identity - )) + .get(format!("{server_url}/v1/database/{identity}/schema?version=10")) .header("authorization", "Bearer not-a-jwt") .send() .expect("schema request failed"); @@ -328,12 +716,8 @@ fn http_routes_end_to_end() { assert!(resp.status().is_success()); } -#[test] -fn http_routes_pr_example_round_trip() { - let test = Smoketest::builder().module_code(EXAMPLE_MODULE_CODE).build(); - let identity = test.database_identity.as_ref().expect("database identity missing"); - - let base = format!("{}/v1/database/{}/route", test.server_url, identity); +fn assert_http_routes_pr_example_round_trip(server_url: &str, identity: &str) { + let base = format!("{server_url}/v1/database/{identity}/route"); let client = reqwest::blocking::Client::new(); let payload = b"hello from the PR example".to_vec(); @@ -368,14 +752,8 @@ fn http_routes_pr_example_round_trip() { assert!(resp.status().is_server_error()); } -#[test] -fn http_routes_are_strict_for_non_root_paths() { - let test = Smoketest::builder() - .module_code(STRICT_NON_ROOT_ROUTING_MODULE_CODE) - .build(); - let identity = test.database_identity.as_ref().expect("database identity missing"); - - let base = format!("{}/v1/database/{}/route", test.server_url, identity); +fn assert_http_routes_are_strict_for_non_root_paths(server_url: &str, identity: &str) { + let base = format!("{server_url}/v1/database/{identity}/route"); let client = reqwest::blocking::Client::new(); let resp = client.get(format!("{base}/foo")).send().expect("foo failed"); @@ -398,14 +776,8 @@ fn http_routes_are_strict_for_non_root_paths() { assert_eq!(resp.text().expect("double slash foo body"), NO_SUCH_ROUTE_BODY); } -#[test] -fn http_routes_are_strict_for_root_paths() { - let test = Smoketest::builder() - .module_code(STRICT_ROOT_ROUTING_MODULE_CODE) - .build(); - let identity = test.database_identity.as_ref().expect("database identity missing"); - - let base = format!("{}/v1/database/{}/route", test.server_url, identity); +fn assert_http_routes_are_strict_for_root_paths(server_url: &str, identity: &str) { + let base = format!("{server_url}/v1/database/{identity}/route"); let client = reqwest::blocking::Client::new(); let resp = client.get(base.clone()).send().expect("empty root failed"); @@ -417,12 +789,8 @@ fn http_routes_are_strict_for_root_paths() { assert_eq!(resp.text().expect("slash root body"), "slash"); } -#[test] -fn http_handler_observes_full_external_uri() { - let test = Smoketest::builder().module_code(FULL_URI_MODULE_CODE).build(); - let identity = test.database_identity.as_ref().expect("database identity missing"); - - let base = format!("{}/v1/database/{}/route", test.server_url, identity); +fn assert_http_handler_observes_full_external_uri(server_url: &str, identity: &str) { + let base = format!("{server_url}/v1/database/{identity}/route"); let url = format!("{base}/echo-uri?alpha=beta"); let client = reqwest::blocking::Client::new(); @@ -431,14 +799,8 @@ fn http_handler_observes_full_external_uri() { assert_eq!(resp.text().expect("echo-uri body"), url); } -#[test] -fn handle_request_body() { - let test = Smoketest::builder() - .module_code(HANDLE_REQUEST_BODY_MODULE_CODE) - .build(); - let identity = test.database_identity.as_ref().expect("database identity missing"); - - let base = format!("{}/v1/database/{}/route", test.server_url, identity); +fn assert_handle_request_body(server_url: &str, identity: &str) { + let base = format!("{server_url}/v1/database/{identity}/route"); let client = reqwest::blocking::Client::new(); let resp = client @@ -502,6 +864,98 @@ fn handle_request_body() { ); } +#[test] +fn http_routes_end_to_end() { + let (test, identity) = rust_http_test(MODULE_CODE); + assert_http_routes_end_to_end(&test.server_url, &identity); +} + +#[test] +fn http_routes_pr_example_round_trip() { + let (test, identity) = rust_http_test(EXAMPLE_MODULE_CODE); + assert_http_routes_pr_example_round_trip(&test.server_url, &identity); +} + +#[test] +fn http_routes_are_strict_for_non_root_paths() { + let (test, identity) = rust_http_test(STRICT_NON_ROOT_ROUTING_MODULE_CODE); + assert_http_routes_are_strict_for_non_root_paths(&test.server_url, &identity); +} + +#[test] +fn http_routes_are_strict_for_root_paths() { + let (test, identity) = rust_http_test(STRICT_ROOT_ROUTING_MODULE_CODE); + assert_http_routes_are_strict_for_root_paths(&test.server_url, &identity); +} + +#[test] +fn http_handler_observes_full_external_uri() { + let (test, identity) = rust_http_test(FULL_URI_MODULE_CODE); + assert_http_handler_observes_full_external_uri(&test.server_url, &identity); +} + +#[test] +fn handle_request_body() { + let (test, identity) = rust_http_test(HANDLE_REQUEST_BODY_MODULE_CODE); + assert_handle_request_body(&test.server_url, &identity); +} + +#[test] +fn cpp_http_routes_end_to_end() { + let (test, identity) = cpp_http_test("http-routes-cpp-basic", "http-routes-cpp-basic", CPP_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", + "http-routes-cpp-example", + CPP_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", + "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 cpp_http_routes_are_strict_for_root_paths() { + let (test, identity) = cpp_http_test( + "http-routes-cpp-strict-root", + "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 cpp_http_handler_observes_full_external_uri() { + let (test, identity) = cpp_http_test( + "http-routes-cpp-full-uri", + "http-routes-cpp-full-uri", + CPP_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", + "http-routes-cpp-request-body", + CPP_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() { @@ -518,3 +972,28 @@ fn http_handlers_tutorial_say_hello_route_works() { assert!(resp.status().is_success()); assert_eq!(resp.text().expect("say-hello body"), "Hello!"); } + +/// Validates the C++ example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. +#[test] +fn cpp_http_handlers_tutorial_say_hello_route_works() { + require_emscripten!(); + + let module_code = extract_cpp_code_blocks( + &workspace_root().join("docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md"), + ); + 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!"); +} diff --git a/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md b/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md index 67589fad0ba..657003f7d38 100644 --- a/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md +++ b/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md @@ -16,6 +16,29 @@ External clients can make HTTP requests to routes nested under [`/v1/database/:n ## Defining HTTP Handlers + + +Define an HTTP handler with `spacetimedb.httpHandler`. + +The function must accept exactly two arguments: + +1. A `HandlerContext`. +2. A `Request`. + +The function must return a `SyncResponse`. + +```typescript +import { schema, SyncResponse } from "spacetimedb/server"; + +const spacetimedb = schema({}); +export default spacetimedb; + +export const say_hello = spacetimedb.httpHandler((_ctx, _req) => { + return new SyncResponse("Hello!"); +}); +``` + + Because HTTP handlers are unstable, Rust modules that define them must opt in to the `unstable` feature in their `Cargo.toml`: @@ -43,6 +66,37 @@ fn say_hello(_ctx: &mut HandlerContext, _req: Request) -> Response { } ``` + + + +Because HTTP handlers are unstable, C++ modules that define them must enable `SPACETIMEDB_UNSTABLE_FEATURES` when compiling. + +Define an HTTP handler with `SPACETIMEDB_HTTP_HANDLER`. + +The function must accept exactly two arguments: + +1. A `SpacetimeDB::HandlerContext`. +2. A `SpacetimeDB::HttpRequest`. + +The function must return a `SpacetimeDB::HttpResponse`. + +```cpp +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(say_hello, HandlerContext ctx, HttpRequest request) { + (void)ctx; + (void)request; + return HttpResponse{ + 200, + HttpVersion::Http11, + { HttpHeader{"content-type", "text/plain; charset=utf-8"} }, + HttpBody::from_string("Hello!"), + }; +} +``` + @@ -51,6 +105,26 @@ fn say_hello(_ctx: &mut HandlerContext, _req: Request) -> Response { Once you've [defined an HTTP handler](#defining-http-handlers), you must register it to a route in order to make it reachable for requests. + + +All routes exposed by your module are declared in a `Router`. Register the `Router` for your database by passing it to `spacetimedb.httpRouter`. + +```typescript +import { Router } from "spacetimedb/server"; + +export const router = spacetimedb.httpRouter( + new Router() + .get("/say-hello", say_hello) +); +``` + +Add routes within a router with the `get`, `head`, `options`, `put`, `delete`, `post`, `patch` and `any` methods, which register an HTTP handler for that HTTP method at a given path. + +Nest routers with `router.nest(prefix, subRouter)`, which causes `subRouter` to handle routing for all paths that start with `prefix`. + +Combine routers with `router.merge(otherRouter)`, which combines both routers. + + All routes exposed by your module are declared in a `spacetimedb::http::Router`. Register the `Router` for your database by returning it from a function annotated with `#[spacetimedb::http::router]`. @@ -71,6 +145,24 @@ Nest routers with `router.nest(prefix, sub_router)`, which causes `sub_router` t Combine routers with `router.merge(other_router)`, which combines both routers. + + + +All routes exposed by your module are declared in a `SpacetimeDB::Router`. Register the `Router` for your database by returning it from a function defined with `SPACETIMEDB_HTTP_ROUTER`. + +```cpp +SPACETIMEDB_HTTP_ROUTER(router) { + return Router() + .get("/say-hello", say_hello); +} +``` + +Add routes within a router with the `get`, `head`, `options`, `put`, `delete_`, `post`, `patch` and `any` methods, which register an HTTP handler for that HTTP method at a given path. + +Nest routers with `router.nest(prefix, sub_router)`, which causes `sub_router` to handle routing for all paths that start with `prefix`. + +Combine routers with `router.merge(other_router)`, which combines both routers. + From e212729155b6eb3ccb765aa729e5a7851a6fd917 Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Wed, 13 May 2026 11:26:35 -0700 Subject: [PATCH 4/7] Mirror changes in TypeScript for smoketests --- .../tests/smoketests/http_routes.rs | 86 ++++++------------- 1 file changed, 28 insertions(+), 58 deletions(-) diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 7c14f82ad2c..3efcda47301 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -589,11 +589,11 @@ SPACETIMEDB_HTTP_ROUTER(router) { const NO_SUCH_ROUTE_BODY: &str = "Database has not registered a handler for this route"; -fn extract_rust_code_blocks(doc_path: &Path) -> String { +fn extract_code_blocks(doc_path: &Path, regex_src: &str, language_name: &str) -> String { let doc = fs::read_to_string(doc_path).unwrap_or_else(|e| panic!("failed to read {}: {e}", doc_path.display())); let doc = doc.replace("\r\n", "\n"); - let re = Regex::new(r"```rust\n([\s\S]*?)\n```").expect("regex should compile"); + let re = Regex::new(regex_src).expect("regex should compile"); let blocks: Vec<_> = re .captures_iter(&doc) .map(|cap| cap.get(1).expect("capture group should exist").as_str().to_string()) @@ -601,26 +601,8 @@ fn extract_rust_code_blocks(doc_path: &Path) -> String { assert!( !blocks.is_empty(), - "expected at least one rust code block in {}", - doc_path.display() - ); - - blocks.join("\n\n") -} - -fn extract_cpp_code_blocks(doc_path: &Path) -> String { - let doc = fs::read_to_string(doc_path).unwrap_or_else(|e| panic!("failed to read {}: {e}", doc_path.display())); - let doc = doc.replace("\r\n", "\n"); - - let re = Regex::new(r"```(?:cpp|c\+\+)\n([\s\S]*?)\n```").expect("regex should compile"); - let blocks: Vec<_> = re - .captures_iter(&doc) - .map(|cap| cap.get(1).expect("capture group should exist").as_str().to_string()) - .collect(); - - assert!( - !blocks.is_empty(), - "expected at least one cpp code block in {}", + "expected at least one {} code block in {}", + language_name, doc_path.display() ); @@ -637,17 +619,21 @@ fn rust_http_test(module_code: &str) -> (Smoketest, String) { (test, identity) } -fn cpp_http_test(module_name: &str, db_name: &str, module_code: &str) -> (Smoketest, String) { +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(module_name, db_name, module_code) + .publish_cpp_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 = format!("{server_url}/v1/database/{identity}/route"); + let base = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let resp = client.get(format!("{base}/get")).send().expect("get failed"); @@ -717,7 +703,7 @@ fn assert_http_routes_end_to_end(server_url: &str, identity: &str) { } fn assert_http_routes_pr_example_round_trip(server_url: &str, identity: &str) { - let base = format!("{server_url}/v1/database/{identity}/route"); + let base = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let payload = b"hello from the PR example".to_vec(); @@ -753,7 +739,7 @@ fn assert_http_routes_pr_example_round_trip(server_url: &str, identity: &str) { } fn assert_http_routes_are_strict_for_non_root_paths(server_url: &str, identity: &str) { - let base = format!("{server_url}/v1/database/{identity}/route"); + let base = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let resp = client.get(format!("{base}/foo")).send().expect("foo failed"); @@ -777,7 +763,7 @@ fn assert_http_routes_are_strict_for_non_root_paths(server_url: &str, identity: } fn assert_http_routes_are_strict_for_root_paths(server_url: &str, identity: &str) { - let base = format!("{server_url}/v1/database/{identity}/route"); + let base = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let resp = client.get(base.clone()).send().expect("empty root failed"); @@ -790,7 +776,7 @@ fn assert_http_routes_are_strict_for_root_paths(server_url: &str, identity: &str } fn assert_http_handler_observes_full_external_uri(server_url: &str, identity: &str) { - let base = format!("{server_url}/v1/database/{identity}/route"); + let base = route_base(server_url, identity); let url = format!("{base}/echo-uri?alpha=beta"); let client = reqwest::blocking::Client::new(); @@ -800,7 +786,7 @@ fn assert_http_handler_observes_full_external_uri(server_url: &str, identity: &s } fn assert_handle_request_body(server_url: &str, identity: &str) { - let base = format!("{server_url}/v1/database/{identity}/route"); + let base = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let resp = client @@ -902,65 +888,47 @@ fn handle_request_body() { #[test] fn cpp_http_routes_end_to_end() { - let (test, identity) = cpp_http_test("http-routes-cpp-basic", "http-routes-cpp-basic", CPP_MODULE_CODE); + 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 cpp_http_routes_pr_example_round_trip() { - let (test, identity) = cpp_http_test( - "http-routes-cpp-example", - "http-routes-cpp-example", - CPP_EXAMPLE_MODULE_CODE, - ); + 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 cpp_http_routes_are_strict_for_non_root_paths() { - let (test, identity) = cpp_http_test( - "http-routes-cpp-strict-non-root", - "http-routes-cpp-strict-non-root", - CPP_STRICT_NON_ROOT_ROUTING_MODULE_CODE, - ); + 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 cpp_http_routes_are_strict_for_root_paths() { - let (test, identity) = cpp_http_test( - "http-routes-cpp-strict-root", - "http-routes-cpp-strict-root", - CPP_STRICT_ROOT_ROUTING_MODULE_CODE, - ); + 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 cpp_http_handler_observes_full_external_uri() { - let (test, identity) = cpp_http_test( - "http-routes-cpp-full-uri", - "http-routes-cpp-full-uri", - CPP_FULL_URI_MODULE_CODE, - ); + 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 cpp_handle_request_body() { - let (test, identity) = cpp_http_test( - "http-routes-cpp-request-body", - "http-routes-cpp-request-body", - CPP_HANDLE_REQUEST_BODY_MODULE_CODE, - ); + 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); } /// Validates the Rust example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. #[test] fn http_handlers_tutorial_say_hello_route_works() { - let module_code = extract_rust_code_blocks( + let module_code = extract_code_blocks( &workspace_root().join("docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md"), + r"```rust\n([\s\S]*?)\n```", + "rust", ); let test = Smoketest::builder().module_code(&module_code).build(); let identity = test.database_identity.as_ref().expect("database identity missing"); @@ -978,8 +946,10 @@ fn http_handlers_tutorial_say_hello_route_works() { fn cpp_http_handlers_tutorial_say_hello_route_works() { require_emscripten!(); - let module_code = extract_cpp_code_blocks( + 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 From 8600c3bc466e5f6d7cee3416453845eb16b7c2e9 Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Thu, 14 May 2026 14:37:26 -0700 Subject: [PATCH 5/7] Lint repair and update from missing autogen --- .../bindings-cpp/include/spacetimedb/http.h | 6 +- .../include/spacetimedb/http_convert.h | 5 ++ .../internal/autogen/HttpMethod.g.h | 75 +++++++++++++++++++ .../internal/autogen/MethodOrAny.g.h | 12 +-- .../bindings-cpp/include/spacetimedb/router.h | 62 +++++++++++++-- crates/smoketests/src/lib.rs | 3 +- .../tests/smoketests/http_routes.rs | 15 ++-- 7 files changed, 148 insertions(+), 30 deletions(-) create mode 100644 crates/bindings-cpp/include/spacetimedb/internal/autogen/HttpMethod.g.h diff --git a/crates/bindings-cpp/include/spacetimedb/http.h b/crates/bindings-cpp/include/spacetimedb/http.h index f24c76a8128..82a55beeb32 100644 --- a/crates/bindings-cpp/include/spacetimedb/http.h +++ b/crates/bindings-cpp/include/spacetimedb/http.h @@ -316,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_convert.h b/crates/bindings-cpp/include/spacetimedb/http_convert.h index 7c02a1fb021..e514f4b864e 100644 --- a/crates/bindings-cpp/include/spacetimedb/http_convert.h +++ b/crates/bindings-cpp/include/spacetimedb/http_convert.h @@ -281,4 +281,9 @@ inline std::pair> to_wire_split(const H } // 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/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 index f266d538c3d..347702f1a61 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/MethodOrAny.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/MethodOrAny.g.h @@ -12,17 +12,9 @@ #include #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" -#include "spacetimedb/http_wire.h" +#include "HttpMethod.g.h" namespace SpacetimeDB::Internal { -SPACETIMEDB_INTERNAL_PRODUCT_TYPE(MethodOrAny_Method_Wrapper) { - SpacetimeDB::wire::HttpMethod value; - void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { - ::SpacetimeDB::bsatn::serialize(writer, value); - } - SPACETIMEDB_PRODUCT_TYPE_EQUALITY(value) -}; - -SPACETIMEDB_INTERNAL_TAGGED_ENUM(MethodOrAny, std::monostate, MethodOrAny_Method_Wrapper) +SPACETIMEDB_INTERNAL_TAGGED_ENUM(MethodOrAny, std::monostate, SpacetimeDB::Internal::HttpMethod) } // namespace SpacetimeDB::Internal diff --git a/crates/bindings-cpp/include/spacetimedb/router.h b/crates/bindings-cpp/include/spacetimedb/router.h index 8c3f3b5191c..e3ee6ca8962 100644 --- a/crates/bindings-cpp/include/spacetimedb/router.h +++ b/crates/bindings-cpp/include/spacetimedb/router.h @@ -6,7 +6,6 @@ #endif #include -#include #include #include #include @@ -141,9 +140,34 @@ class Router { if (a.method.is<0>() || b.method.is<0>()) { return true; } - const auto& a_method = a.method.template get<1>().value; - const auto& b_method = b.method.template get<1>().value; - return a_method == b_method; + 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() { @@ -154,9 +178,33 @@ class Router { static Internal::MethodOrAny make_method(const HttpMethod& method) { Internal::MethodOrAny result; - Internal::MethodOrAny_Method_Wrapper wrapper; - wrapper.value = convert::to_wire(method); - result.set<1>(wrapper); + 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; } diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 088b51611db..93746dfbc4e 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -1013,8 +1013,7 @@ impl Smoketest { .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(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")?; diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 3efcda47301..1e5e7b5d270 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -622,9 +622,7 @@ fn rust_http_test(module_code: &str) -> (Smoketest, String) { 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(); + let identity = test.publish_cpp_module_source(name, name, module_code).unwrap(); (test, identity) } @@ -900,7 +898,10 @@ fn cpp_http_routes_pr_example_round_trip() { #[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); + 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); } @@ -953,11 +954,7 @@ fn cpp_http_handlers_tutorial_say_hello_route_works() { ); 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, - ) + .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); From 2c705f6a2e8feaf7234914a0195239ef7ddfab57 Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Fri, 15 May 2026 11:32:57 -0700 Subject: [PATCH 6/7] Clean up of overuse of (void) --- .../error_http_handler_immutable_ctx.cpp | 2 -- .../error_http_handler_no_connection_id.cpp | 1 - .../error_http_handler_no_db.cpp | 1 - .../error_http_handler_no_request_arg.cpp | 1 - .../error_http_handler_no_return_type.cpp | 2 -- .../error_http_handler_no_sender.cpp | 1 - .../error_http_handler_wrong_ctx.cpp | 2 -- ...or_http_handler_wrong_request_arg_type.cpp | 2 -- .../error_http_handler_wrong_return_type.cpp | 2 -- .../error_http_router_with_args.cpp | 3 -- .../http-handlers/ok_http_handlers_basic.cpp | 2 -- .../tests/smoketests/http_routes.rs | 28 ------------------- 12 files changed, 47 deletions(-) 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 index 1832f28496a..acb7d2f731a 100644 --- 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 @@ -3,8 +3,6 @@ using namespace SpacetimeDB; SPACETIMEDB_HTTP_HANDLER(handler_immutable_ctx, const HandlerContext& ctx, HttpRequest request) { - (void)ctx; - (void)request; return HttpResponse{ 200, HttpVersion::Http11, 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 index cbebae53852..c775e6c3430 100644 --- 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 @@ -3,7 +3,6 @@ using namespace SpacetimeDB; SPACETIMEDB_HTTP_HANDLER(handler_no_connection_id, HandlerContext ctx, HttpRequest request) { - (void)request; auto conn_id = ctx.connection_id(); return HttpResponse{ 200, 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 index 707b2183be0..4f650b55e71 100644 --- 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 @@ -9,7 +9,6 @@ SPACETIMEDB_STRUCT(TestRow, value) SPACETIMEDB_TABLE(TestRow, test_row, Public) SPACETIMEDB_HTTP_HANDLER(handler_no_db, HandlerContext ctx, HttpRequest request) { - (void)request; auto count = ctx.db[test_row].count(); return HttpResponse{ 200, 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 index ac2eebad2d2..4543a97ef8c 100644 --- 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 @@ -3,7 +3,6 @@ using namespace SpacetimeDB; SPACETIMEDB_HTTP_HANDLER(handler_no_request_arg, HandlerContext ctx) { - (void)ctx; return HttpResponse{ 200, HttpVersion::Http11, 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 index cbc5cd4f29f..ea22473d38f 100644 --- 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 @@ -7,6 +7,4 @@ using namespace SpacetimeDB; #endif SPACETIMEDB_HTTP_HANDLER(handler_no_return_type, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)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 index 6d84d5e4af1..9f4ba51b5d0 100644 --- 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 @@ -3,7 +3,6 @@ using namespace SpacetimeDB; SPACETIMEDB_HTTP_HANDLER(handler_no_sender, HandlerContext ctx, HttpRequest request) { - (void)request; auto sender = ctx.sender(); return HttpResponse{ 200, 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 index f2637cfc53c..01893c6d278 100644 --- 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 @@ -3,8 +3,6 @@ using namespace SpacetimeDB; SPACETIMEDB_HTTP_HANDLER(handler_wrong_ctx, ProcedureContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return HttpResponse{ 200, HttpVersion::Http11, 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 index 5c06d017dba..6ee189776b6 100644 --- 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 @@ -3,8 +3,6 @@ using namespace SpacetimeDB; SPACETIMEDB_HTTP_HANDLER(handler_wrong_request_arg_type, HandlerContext ctx, uint32_t request) { - (void)ctx; - (void)request; return HttpResponse{ 200, HttpVersion::Http11, 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 index ca22aedef90..b04c6bfaa3e 100644 --- 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 @@ -3,7 +3,5 @@ using namespace SpacetimeDB; SPACETIMEDB_HTTP_HANDLER(handler_wrong_return_type, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return 7u; } 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 index 173b0b7deca..c4f7fb6a5d3 100644 --- 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 @@ -3,8 +3,6 @@ using namespace SpacetimeDB; SPACETIMEDB_HTTP_HANDLER(hello_handler, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return HttpResponse{ 200, HttpVersion::Http11, @@ -14,6 +12,5 @@ SPACETIMEDB_HTTP_HANDLER(hello_handler, HandlerContext ctx, HttpRequest request) } SPACETIMEDB_HTTP_ROUTER(register_http_routes, HandlerContext ctx) { - (void)ctx; return Router().get("/hello", hello_handler); } 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 index 2bcdd546426..e482c0561df 100644 --- 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 @@ -3,8 +3,6 @@ using namespace SpacetimeDB; SPACETIMEDB_HTTP_HANDLER(hello_handler, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return HttpResponse{ 200, HttpVersion::Http11, diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 1e5e7b5d270..a7ce4ec4bf9 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -264,13 +264,10 @@ HttpResponse text_response(uint16_t status_code, std::string body) { } // namespace SPACETIMEDB_HTTP_HANDLER(get_simple, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return text_response(200, "ok"); } SPACETIMEDB_HTTP_HANDLER(post_insert, HandlerContext ctx, HttpRequest request) { - (void)request; ctx.with_tx([](TxContext& tx) { uint64_t id = tx.db[entry].count(); tx.db[entry].insert(Entry{ id, "posted" }); @@ -279,7 +276,6 @@ SPACETIMEDB_HTTP_HANDLER(post_insert, HandlerContext ctx, HttpRequest request) { } SPACETIMEDB_HTTP_HANDLER(get_count, HandlerContext ctx, HttpRequest request) { - (void)request; uint64_t count = ctx.with_tx([](TxContext& tx) -> uint64_t { return tx.db[entry].count(); }); @@ -287,19 +283,14 @@ SPACETIMEDB_HTTP_HANDLER(get_count, HandlerContext ctx, HttpRequest request) { } SPACETIMEDB_HTTP_HANDLER(any_handler, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return text_response(200, "any"); } SPACETIMEDB_HTTP_HANDLER(header_echo, HandlerContext ctx, HttpRequest request) { - (void)ctx; return text_response(200, header_value_utf8(request, "x-echo")); } SPACETIMEDB_HTTP_HANDLER(set_response_header, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return HttpResponse{ 200, HttpVersion::Http11, @@ -309,14 +300,10 @@ SPACETIMEDB_HTTP_HANDLER(set_response_header, HandlerContext ctx, HttpRequest re } SPACETIMEDB_HTTP_HANDLER(body_handler, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return text_response(200, "non-empty"); } SPACETIMEDB_HTTP_HANDLER(teapot, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return text_response(418, "teapot"); } @@ -443,26 +430,18 @@ HttpResponse text_response(const std::string& body) { } // namespace SPACETIMEDB_HTTP_HANDLER(empty_root, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return text_response("empty"); } SPACETIMEDB_HTTP_HANDLER(slash_root, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return text_response("slash"); } SPACETIMEDB_HTTP_HANDLER(foo, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return text_response("foo"); } SPACETIMEDB_HTTP_HANDLER(foo_slash, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return text_response("foo-slash"); } @@ -488,14 +467,10 @@ HttpResponse text_response(const std::string& body) { } // namespace SPACETIMEDB_HTTP_HANDLER(foo, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return text_response("foo"); } SPACETIMEDB_HTTP_HANDLER(foo_slash, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return text_response("foo-slash"); } @@ -511,7 +486,6 @@ const CPP_FULL_URI_MODULE_CODE: &str = r#"#include "spacetimedb.h" using namespace SpacetimeDB; SPACETIMEDB_HTTP_HANDLER(echo_uri, HandlerContext ctx, HttpRequest request) { - (void)ctx; return HttpResponse{ 200, HttpVersion::Http11, @@ -543,14 +517,12 @@ HttpResponse text_response(uint16_t status_code, const std::string& body) { } // namespace SPACETIMEDB_HTTP_HANDLER(reverse_bytes, HandlerContext ctx, HttpRequest request) { - (void)ctx; 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) { - (void)ctx; const std::vector bytes = request.body.to_bytes(); std::string body(bytes.begin(), bytes.end()); if (body.find(static_cast(0x80)) != std::string::npos) { From 3e25d8ddece05e89f87cef41302751606fb1cca7 Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Fri, 15 May 2026 12:17:56 -0700 Subject: [PATCH 7/7] Docs cleanup --- .../00200-core-concepts/00200-functions/00600-HTTP-handlers.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md b/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md index 657003f7d38..185d53be561 100644 --- a/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md +++ b/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md @@ -86,8 +86,6 @@ The function must return a `SpacetimeDB::HttpResponse`. using namespace SpacetimeDB; SPACETIMEDB_HTTP_HANDLER(say_hello, HandlerContext ctx, HttpRequest request) { - (void)ctx; - (void)request; return HttpResponse{ 200, HttpVersion::Http11,