Files
Tyler Cloutier d5c75d944d Ungate procedures from the unstable feature (#5164)
# Description of Changes

Graduate **procedures** (and the outgoing HTTP client used from
procedures) out of the `unstable` feature gate /
`SPACETIMEDB_UNSTABLE_FEATURES` across all module libraries. The
`procedure` macro, `ProcedureContext`, `with_tx`/`try_with_tx`,
scheduled procedures, and `ctx.http` are now available without opting
into `unstable`.

**Still gated** (unchanged): HTTP handlers/webhooks, views, RLS /
`client_visibility_filter`, and immediate-scheduling
(`volatile_nonatomic_schedule_immediate`).

Per library:
- **`bindings-sys`**: ungate the `procedure` host-call module + raw ABI
imports (`procedure_start/commit/abort_mut_tx`,
`procedure_http_request`) and `call_no_ret`. Scheduling ABI stays gated.
- **`bindings` (Rust)**: ungate the `procedure` macro,
`ProcedureContext`, `TxContext`/`with_tx`/`try_with_tx`,
`db_read_only`/`get_read_only`, the procedure traits,
`register_procedure`, `__call_procedure__`, and procedure RNG. The
`http` module (previously gated as a unit) now has fine-grained gating
so the outgoing `HttpClient` is exposed while
`HandlerContext`/`Router`/handler macros stay gated.
- **`bindings-csharp`**: drop `[Experimental("STDB_UNSTABLE")]` from
`ProcedureContext.WithTx/TryWithTx` (runtime + codegen) and the
generated `ProcedureTxContext`; FFI snapshots regenerated. Handler
context members + RLS attribute stay gated.
- **`bindings-cpp`**: ungate the procedure ABI (`abi.h`/`FFI.h`),
`tx_execution.h`, the outgoing HTTP client (`http.h`/`http_convert.h`),
and `procedure_context.h`.
`handler_context.h`/`router.h`/`http_handler_macros.h` still `#error`
without `SPACETIMEDB_UNSTABLE_FEATURES`.
- **docs**: remove the procedures beta notices (HTTP handlers stay
marked beta for a later release); regenerate `static/llms.md`.
- **`sdk-test-procedure`**: no longer needs `features = ["unstable"]`.

# API and ABI breaking changes

Not breaking. This is purely additive to the stable surface: procedures
become available **without** the `unstable` feature, while modules that
already opt into `unstable` are unaffected. The wasm host-ABI imports
were already implemented host-side and merely gated module-side, so
there is no ABI version change. (No breaking-change label needed.)

# Expected complexity level and risk

**3/5.** The diff itself is mostly removing
`#[cfg]`/`#ifdef`/`[Experimental]` guards, but the gate bundled
procedures + outgoing HTTP + handlers + the ABI crate together, so the
work was in *separating* procedures from the features that stay gated.
Areas reviewers may want to scrutinize:
- The Rust `http` module went from "gated as a whole" to fine-grained
per-item gating; the outgoing `HttpClient` is exposed while handler
types/macros stay behind `unstable`.
- The `unstable` cut reaches the ABI crate (`bindings-sys`), which is
the only way procedures can compile without the feature.
- C++ separation of the outgoing HTTP client from HTTP handlers across
several headers.

# Testing

- [x] Rust: `cargo check -p spacetimedb` passes in default, `--features
unstable`, and `--no-default-features --features unstable`.
- [x] Rust end-to-end: `sdk-test-procedure` (uses procedures,
`with_tx`/`try_with_tx`, `ctx.http.get`, `new_uuid_v7`, scheduled
procedures) builds **without** `unstable`.
- [x] C#: Codegen + Runtime build; `Codegen.Tests` pass (6/6) after FFI
snapshot regen.
- [x] C++: host syntax-check confirms procedures + `ctx.http.send()`
compile **without** the flag, full headers compile **with** it, and
`handler_context.h` still `#error`s without it. (Not built for the real
wasm/emscripten target locally — relying on CI.)
- [x] `cargo ci lint` components reproduced locally: rustfmt, clippy
(`-D warnings`, default + `unstable`), csharpier, and `cargo doc --deny
warnings`.
- [ ] Reviewer: confirm the wasm bindings + C#/C++ test suites pass in
CI (especially the C++ wasm build, which couldn't run locally).

---------

Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com>
2026-06-05 16:09:36 +00:00

319 lines
9.7 KiB
C++

#ifndef SPACETIMEDB_HTTP_H
#define SPACETIMEDB_HTTP_H
#pragma once
#include <string>
#include <vector>
#include <optional>
#include <cstdint>
#include "spacetimedb/bsatn/time_duration.h"
#include "spacetimedb/outcome.h"
/**
* @file http.h
* @brief HTTP request support for SpacetimeDB procedures
*
* This module provides types and functionality for making outbound HTTP requests
* from within SpacetimeDB procedures. The actual network I/O is performed by the
* SpacetimeDB host using reqwest (Rust), ensuring security and resource management.
*
* IMPORTANT LIMITATIONS:
* - HTTP requests CANNOT be performed inside with_tx() or try_with_tx()
* - The host will reject HTTP requests with WOULD_BLOCK_TRANSACTION
* - All timeouts are clamped to a maximum of 500ms by the host
* - No external HTTP library dependencies (host does actual HTTP)
*
* Example usage:
* @code
* SPACETIMEDB_PROCEDURE(std::string, fetch_data, ProcedureContext ctx) {
* auto result = ctx.http.get("http://api.example.com/data");
*
* if (result.is_ok()) {
* auto& response = result.value();
* return Ok(response.body.to_string_utf8_lossy());
* } else {
* return Err("HTTP error: " + result.error());
* }
* }
* @endcode
*
* @ingroup sdk_runtime
*/
namespace SpacetimeDB {
/**
* @brief HTTP method (e.g., GET, POST, PUT, DELETE)
*
* Supports all standard HTTP methods plus custom extension methods.
* Extension methods are any string value not matching a standard method.
*/
struct HttpMethod {
std::string value;
/// Standard HTTP methods
static HttpMethod get() { return HttpMethod{"GET"}; }
static HttpMethod head() { return HttpMethod{"HEAD"}; }
static HttpMethod post() { return HttpMethod{"POST"}; }
static HttpMethod put() { return HttpMethod{"PUT"}; }
// DELETE cannot be named "delete" in C++; provide snake_case aliases
static HttpMethod del() { return HttpMethod{"DELETE"}; }
static HttpMethod http_delete() { return HttpMethod{"DELETE"}; }
static HttpMethod connect() { return HttpMethod{"CONNECT"}; }
static HttpMethod options() { return HttpMethod{"OPTIONS"}; }
static HttpMethod trace() { return HttpMethod{"TRACE"}; }
static HttpMethod patch() { return HttpMethod{"PATCH"}; }
/// Create a custom/extension HTTP method
explicit HttpMethod(std::string v) : value(std::move(v)) {}
};
/**
* @brief HTTP protocol version
*/
enum class HttpVersion : uint8_t {
Http09, ///< HTTP/0.9
Http10, ///< HTTP/1.0
Http11, ///< HTTP/1.1 (default)
Http2, ///< HTTP/2
Http3, ///< HTTP/3
};
/**
* @brief HTTP header name/value pair
*
* Header values are always treated as raw bytes. The is_sensitive flag
* is a local-only hint and is not transmitted to the host (sensitive
* headers have their names redacted in wire format).
*/
struct HttpHeader {
std::string name;
std::vector<uint8_t> value;
bool is_sensitive = false;
/**
* @brief Create header from string name and string value
* @param n Header name
* @param v Header value (converted to ASCII bytes)
* @param sensitive If true, header name will be redacted in logs/wire format
*/
HttpHeader(std::string n, std::string v, bool sensitive = false)
: name(std::move(n))
, value(v.begin(), v.end())
, is_sensitive(sensitive)
{}
/**
* @brief Create header from string name and byte value
* @param n Header name
* @param v Header value as raw bytes
* @param sensitive If true, header name will be redacted in logs/wire format
*/
HttpHeader(std::string n, std::vector<uint8_t> v, bool sensitive = false)
: name(std::move(n))
, value(std::move(v))
, is_sensitive(sensitive)
{}
};
/**
* @brief HTTP request/response body
*
* Bodies are always treated as raw bytes. Use helper methods for UTF-8 text.
*/
struct HttpBody {
std::vector<uint8_t> bytes;
/// Create an empty body
static HttpBody empty() {
return HttpBody{std::vector<uint8_t>()};
}
/// Create body from UTF-8 string
static HttpBody from_string(const std::string& s) {
return HttpBody{std::vector<uint8_t>(s.begin(), s.end())};
}
/// Get body bytes
std::vector<uint8_t> to_bytes() const {
return bytes;
}
/// Convert body to UTF-8 string (lossy conversion)
std::string to_string_utf8_lossy() const {
return std::string(bytes.begin(), bytes.end());
}
/// Check if body is empty
bool is_empty() const {
return bytes.empty();
}
};
/**
* @brief HTTP request to be executed by the SpacetimeDB host
*
* Use designated initializers (C++20) to construct requests:
* @code
* HttpRequest request{
* .uri = "http://example.com/api",
* .method = HttpMethod::post(),
* .headers = {HttpHeader{"Content-Type", "application/json"}},
* .body = HttpBody::from_string("{\"key\": \"value\"}"),
* .timeout = TimeDuration::from_millis(100)
* };
* @endcode
*
* The host clamps all timeouts to a maximum of 500ms.
*/
struct HttpRequest {
std::string uri;
HttpMethod method = HttpMethod::get();
std::vector<HttpHeader> headers;
HttpBody body = HttpBody::empty();
HttpVersion version = HttpVersion::Http11;
std::optional<TimeDuration> timeout;
};
/**
* @brief HTTP response returned by the SpacetimeDB host
*
* A non-2xx status code is still returned as a successful response; callers should
* inspect status_code to handle application-level errors from the remote server.
*/
struct HttpResponse {
uint16_t status_code;
HttpVersion version;
std::vector<HttpHeader> headers;
HttpBody body;
};
/**
* @brief HTTP client for making outbound HTTP requests via the host
*
* Available from ProcedureContext.http
*
* Returns Outcome<HttpResponse> where:
* - Ok(response): Request succeeded (including non-2xx status codes)
* - Err(message): Transport error (DNS, connection, timeout)
*
* IMPORTANT: Do NOT call inside WithTx() or TryWithTx()
* The host will reject HTTP requests while a transaction is open
* and return WOULD_BLOCK_TRANSACTION error.
*/
class HttpClient {
public:
/**
* @brief Send a simple GET request
*
* @param uri The request URI
* @param timeout Optional timeout (clamped to 500ms by host)
* @return Outcome<HttpResponse> - Ok if response received, Err if transport failed
*
* @code
* auto result = ctx.http.get("http://localhost:3000/v1/database/schema");
* if (result.is_ok()) {
* auto& response = result.value();
* return Ok(response.body.to_string_utf8_lossy());
* } else {
* return Err("HTTP error: " + result.error());
* }
* @endcode
*/
Outcome<HttpResponse> get(
const std::string& uri,
std::optional<TimeDuration> timeout = std::nullopt
) {
HttpRequest request{
.uri = uri,
.method = HttpMethod::get(),
.headers = {},
.body = HttpBody::empty(),
.version = HttpVersion::Http11,
.timeout = timeout
};
return send(request);
}
/**
* @brief Send an HTTP request
*
* @param request The HTTP request to send
* @return Outcome<HttpResponse> - Ok if response received, Err if transport failed
*
* This method does not throw for expected failures; errors are returned as Outcome::Err.
*
* Example with POST:
* @code
* auto request = HttpRequest{
* .uri = "https://api.example.com/upload",
* .method = HttpMethod::post(),
* .headers = {HttpHeader{"Content-Type", "text/plain"}},
* .body = HttpBody::from_string("This is the request body"),
* .timeout = TimeDuration::from_millis(100)
* };
*
* auto result = ctx.http.send(request);
* if (result.is_ok()) {
* auto& response = result.value();
* return Ok("Status: " + std::to_string(response.status_code));
* } else {
* return Err("Error: " + result.error());
* }
* @endcode
*
* Example handling 404:
* @code
* auto result = ctx.http.get("https://example.com/missing");
* if (!result.is_ok()) {
* // Transport error (DNS failure, connection drop, timeout, etc.)
* return Err("Transport error: " + result.error());
* }
*
* auto& response = result.value();
* if (response.status_code != 200) {
* // Application-level HTTP error response
* return Err("HTTP status: " + std::to_string(response.status_code));
* }
*
* return Ok(response.body.to_string_utf8_lossy());
* @endcode
*
* Example showing transaction blocking:
* @code
* // ✗ WRONG: This will fail with WOULD_BLOCK_TRANSACTION
* ctx.WithTx([](TxContext& tx) {
* auto result = ctx.http.get("https://example.com/");
* // ERROR: HTTP blocked in transaction
* });
*
* // ✓ CORRECT: HTTP before transaction
* auto api_result = ctx.http.get("https://example.com/");
* if (api_result.is_ok()) {
* ctx.WithTx([&api_result](TxContext& tx) {
* // Use api_result data here
* });
* }
* @endcode
*/
Outcome<HttpResponse> send(const HttpRequest& request) {
// Implemented in http_client_impl.h to avoid circular dependencies
return SendImpl(request);
}
private:
Outcome<HttpResponse> SendImpl(const HttpRequest& request);
};
} // namespace SpacetimeDB
// Include implementation dependencies after class definition to avoid circular dependencies
#ifndef SPACETIMEDB_HTTP_CONVERT_H
#include "spacetimedb/logger.h"
#include "spacetimedb/http_convert.h"
#include "spacetimedb/http_client_impl.h"
#endif
#endif // SPACETIMEDB_HTTP_H