mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-07-24 19:12:59 -04:00
5c04860649
# Description of Changes
Adds support to Rust modules and the SpacetimeDB host for defining HTTP
handlers and registering them to routes.
## User-facing API
In a Rust module, users can annotate functions with the new macro
`#[spacetimedb::http::handler]`. A function annotated this way must
accept exactly two arguments, of types `&mut
spacetimedb::http::HandlerContext` and `spacetimedb::http::Request`
(which is a type alias for `http::Request<spacetimedb::http::Body>`. It
must also return `spacetimedb::http::Response` (which is a type alias
for `http::Response<spacetimedb::http::Body>`).
Once the user has defined an HTTP handler, they can register it to a
route by annotating a function with `#[spacetimedb::http::router]`. Such
a function must take no arguments and return a
`spacetimedb::http::Router`. (The original design put this annotation on
a `static` variable rather than a function, but that turned out to be
undesirable because it required that constructing a `Router` be
`const`.) `Router` exposes various methods for registering handlers to
routes.
All of a database's user-defined routes are exposed under
`/v1/database/:name_or_identity/route/{*path}`.
## Example
See [the new
smoketest](https://github.com/clockworklabs/SpacetimeDB/blob/phoebe/http-handlers-webhooks/crates/smoketests/tests/smoketests/http_routes.rs)
for a more exhaustive example.
A simpler example, which stores arbitrary byte data in a table via a
`POST` request, returns an ID, and then retrieves that same data via a
`GET` request with a query parameter:
```rust
#[spacetimedb::table(accessor = data)]
struct Data {
#[primary_key]
#[auto_inc]
id: u64,
body: Vec<u8>,
}
#[spacetimedb::http::handler]
fn insert(ctx: &mut HandlerContext, request: Request) -> Response {
let body: Vec<u8> = request.into_body().into_bytes().into();
let id = ctx.with_tx(|tx| tx.db.data().insert(Data { id: 0, body: body.clone() }).id);
Response::new(Body::from_bytes(format!("{id}")))
}
#[spacetimedb::http::handler]
fn retrieve(ctx: &mut HandlerContext, request: Request) -> Response {
let id = request
.uri()
.query()
.and_then(|query| query.strip_prefix("id="))
.and_then(|id| u64::from_str(id).ok())
.unwrap();
let body = ctx.with_tx(|tx| tx.db.data().id().find(id).map(|data| data.body));
if let Some(body) = body {
Response::new(Body::from_bytes(body))
} else {
Response::builder().status(404).body(Body::empty()).unwrap()
}
}
#[spacetimedb::http::router]
fn router() -> Router {
Router::new().post("/insert", insert).get("/retrieve", retrieve)
}
```
## Design and implementation notes
- As mentioned above, the router is registered via a function, not a
`static` or `const` item. This is because `static` or `const`
initializers must be `const`, and it turns out to be a pain to make all
of the `Router` constructors be `const fn`s.
- The `#[handler]` macro clobbers the original function name with a
`const` variable of type `HttpHandler`. This is unfortunate, but AFAICT
necessary, 'cause we need to pass the string identifier for the handler
to the `Router`, not the function pointer, and Rust allows no (stable
and reliable) way to get a unique string identifier out of a function
item/value, nor to attach data or implement traits for function
items/values. The alternative(s) would involve changing the signature of
the `Router` methods to have uglier and more complex callsites, e.g.
like `.get("/retrieve", retrieve::handler())`, `.get("/retrieve",
handler!(retrieve))` or `.get::<retrieve>("/retrieve")`. I believe that
registering handlers will be much more common than calling their
functions, so I've chosen to make it so that registering them gets the
convenient syntax, even though the inability to call them directly will
be somewhat surprising.
- I haven't wired up energy handling or timing metrics for handler
execution to anywhere. Procedures are still in the same boat.
- HTTP requests to user-defined routes bypass the usual SpacetimeDB auth
middleware, meaning that the host does not validate (or inspect in any
way) `Authorization` headers in requests before invoking the
user-defined handler. This is required to allow arbitrary
user-programmable handling of `Authorization` headers, including those
in formations which SpacetimeDB would reject. As a result of this,
`HandlerContext` doesn't expose a `sender` or `sender_connection_id`.
- HTTP route paths may consist only of a very restrictive set of
characters. I've chosen this set to keep our options open in the future
to add additional syntax to routes, like for registering wildcard
segments and path parameters:
- ASCII digits.
- ASCII letters.
- `-_~/`.
- The internal data structure that represents a `Router` is currently a
`Vec<Route>`, meaning that resolving a request to a route is
`O(num_routes)`. Registering a route checks against each previous route
for uniqueness, meaning that constructing a router is `O(num_routes ^
2)`. There are TODO comments to use a trie, but I think this can wait,
as I expect most databases to register few routes.
- Commit 999a7c317 contains a fix to a mostly-unrelated bug where a few
bindings introduced by the SATS derive macros were unhygienic and not in
a reserved namespace, leading to name conflicts. I discovered this
'cause I tried writing an HTTP handler named `index` to serve the
index/root of a website and it broke.
## Still TODO
- [x] Resolve various TODO comments in the diff.
- [x] Documentation.
- [x] C# bindings support.
- [x] C++ bindings support.
- [x] V8 host support.
- [x] TypeScript bindings support.
# API and ABI breaking changes
New APIs, currently flagged as `unstable`, which will eventually need
stability guarantees. No (intentional) breaking changes, or changes to
existing APIs at all.
# Expected complexity level and risk
3? Changes to our HTTP routing to support the user-defined routes.
# Testing
<!-- Describe any testing you've done, and any testing you'd like your
reviewers to do,
so that you're confident that all the changes work as expected! -->
- [x] New smoketest of the behavior!
- [x] I dunno, maybe try hosting a simple webpage and see how it works?
- [x] Build a test app with Stripe integration.
- @aasoni did this.
---------
Co-authored-by: clockwork-labs-bot <clockwork-labs-bot@users.noreply.github.com>
Co-authored-by: Jason Larabie <jason@clockworklabs.io>
355 lines
11 KiB
C++
355 lines
11 KiB
C++
#ifndef SPACETIMEDB_HTTP_WIRE_H
|
|
#define SPACETIMEDB_HTTP_WIRE_H
|
|
|
|
#pragma once
|
|
|
|
#include <string>
|
|
#include <vector>
|
|
#include <optional>
|
|
#include <cstdint>
|
|
#include "spacetimedb/bsatn/bsatn.h"
|
|
#include "spacetimedb/bsatn/traits.h"
|
|
#include "spacetimedb/bsatn/time_duration.h"
|
|
|
|
/**
|
|
* @file http_wire.h
|
|
* @brief BSATN wire format types for HTTP requests/responses
|
|
*
|
|
* These types mirror the Rust types in `spacetimedb_lib::http` and are used for
|
|
* BSATN encoding/decoding when communicating with the SpacetimeDB host.
|
|
*
|
|
* CRITICAL: Field order MUST match Rust exactly for BSATN compatibility!
|
|
*
|
|
* These types are internal implementation details. User code should use the types
|
|
* in http.h instead. Conversion functions in http_conversions.h handle the mapping.
|
|
*
|
|
* @warning Do NOT change the field order or layout of these types without coordinating
|
|
* with the Rust side. Breaking BSATN compatibility will cause runtime failures.
|
|
*
|
|
* @ingroup sdk_internal
|
|
*/
|
|
|
|
namespace SpacetimeDB {
|
|
namespace wire {
|
|
|
|
/**
|
|
* @brief Wire format for HTTP method
|
|
*
|
|
* Matches Rust: `spacetimedb_lib::http::Method`
|
|
*
|
|
* BSATN enum representation:
|
|
* - Standard methods (Get, Head, Post, etc.) are represented as unit variants (no payload)
|
|
* - Extension(String) is represented as a variant with a String payload
|
|
*/
|
|
struct HttpMethod {
|
|
enum class Tag : uint8_t {
|
|
Get = 0,
|
|
Head = 1,
|
|
Post = 2,
|
|
Put = 3,
|
|
Delete = 4,
|
|
Connect = 5,
|
|
Options = 6,
|
|
Trace = 7,
|
|
Patch = 8,
|
|
Extension = 9,
|
|
};
|
|
|
|
Tag tag;
|
|
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
|
|
*
|
|
* Matches Rust: `spacetimedb_lib::http::Version`
|
|
*
|
|
* BSATN enum representation (unit variants only):
|
|
* - Http09 = 0
|
|
* - Http10 = 1
|
|
* - Http11 = 2
|
|
* - Http2 = 3
|
|
* - Http3 = 4
|
|
*/
|
|
struct HttpVersion {
|
|
enum class Tag : uint8_t {
|
|
Http09 = 0,
|
|
Http10 = 1,
|
|
Http11 = 2,
|
|
Http2 = 3,
|
|
Http3 = 4,
|
|
};
|
|
|
|
Tag tag;
|
|
};
|
|
|
|
/**
|
|
* @brief Wire format for a single HTTP header name/value pair
|
|
*
|
|
* Matches Rust: `spacetimedb_lib::http::HttpHeaderPair`
|
|
*
|
|
* Field order: name, value (MUST match Rust!)
|
|
*
|
|
* Note: The `is_sensitive` flag from the user-facing HttpHeader type is NOT transmitted.
|
|
* It's a local-only hint and is not part of the wire format.
|
|
*/
|
|
struct HttpHeaderPair {
|
|
std::string name; // Field 0: Header name (valid HTTP header name)
|
|
std::vector<uint8_t> value; // Field 1: Header value bytes
|
|
};
|
|
|
|
/**
|
|
* @brief Wire format for HTTP headers collection
|
|
*
|
|
* Matches Rust: `spacetimedb_lib::http::Headers`
|
|
*
|
|
* Field order: entries (MUST match Rust!)
|
|
*
|
|
* BSATN representation:
|
|
* - Single field `entries` which is a Vec<HttpHeaderPair>
|
|
* - Headers with the same name appear as multiple entries
|
|
*/
|
|
struct HttpHeaders {
|
|
std::vector<HttpHeaderPair> entries; // Field 0: Array of header pairs
|
|
};
|
|
|
|
/**
|
|
* @brief Wire format for HTTP request
|
|
*
|
|
* Matches Rust: `spacetimedb_lib::http::Request`
|
|
*
|
|
* Field order (CRITICAL - MUST match Rust exactly!):
|
|
* 0. method: HttpMethod
|
|
* 1. headers: HttpHeaders
|
|
* 2. timeout: Option<TimeDuration>
|
|
* 3. uri: String
|
|
* 4. version: HttpVersion
|
|
*
|
|
* Note: The request body is NOT part of this struct. It's passed separately
|
|
* to the host via the ConsumeBytes() mechanism.
|
|
*/
|
|
struct HttpRequest {
|
|
HttpMethod method; // Field 0
|
|
HttpHeaders headers; // Field 1
|
|
std::optional<TimeDuration> timeout; // Field 2
|
|
std::string uri; // Field 3
|
|
HttpVersion version; // Field 4
|
|
};
|
|
|
|
/**
|
|
* @brief Wire format for HTTP response
|
|
*
|
|
* Matches Rust: `spacetimedb_lib::http::Response`
|
|
*
|
|
* Field order (CRITICAL - MUST match Rust exactly!):
|
|
* 0. headers: HttpHeaders
|
|
* 1. version: HttpVersion
|
|
* 2. code: u16
|
|
*
|
|
* Note: The response body is NOT part of this struct. It's received separately
|
|
* from the host via the ConsumeBytes() mechanism.
|
|
*/
|
|
struct HttpResponse {
|
|
HttpHeaders headers; // Field 0
|
|
HttpVersion version; // Field 1
|
|
uint16_t code; // Field 2: HTTP status code
|
|
};
|
|
|
|
} // namespace wire
|
|
} // namespace SpacetimeDB
|
|
|
|
// ==================== BSATN Serialization Traits ====================
|
|
|
|
namespace SpacetimeDB::bsatn {
|
|
|
|
// Forward declarations for recursive types
|
|
template<> struct bsatn_traits<wire::HttpMethod>;
|
|
template<> struct bsatn_traits<wire::HttpVersion>;
|
|
template<> struct bsatn_traits<wire::HttpHeaderPair>;
|
|
template<> struct bsatn_traits<wire::HttpHeaders>;
|
|
template<> struct bsatn_traits<wire::HttpRequest>;
|
|
template<> struct bsatn_traits<wire::HttpResponse>;
|
|
|
|
// HttpMethod enum serialization
|
|
template<>
|
|
struct bsatn_traits<wire::HttpMethod> {
|
|
static void serialize(Writer& writer, const wire::HttpMethod& value) {
|
|
// Encode tag as u8
|
|
writer.write_u8(static_cast<uint8_t>(value.tag));
|
|
|
|
// If Extension variant, encode the string payload
|
|
if (value.tag == wire::HttpMethod::Tag::Extension) {
|
|
bsatn::serialize(writer, value.extension);
|
|
}
|
|
}
|
|
|
|
static wire::HttpMethod deserialize(Reader& reader) {
|
|
wire::HttpMethod result;
|
|
result.tag = static_cast<wire::HttpMethod::Tag>(reader.read_u8());
|
|
|
|
// If Extension variant, decode the string payload
|
|
if (result.tag == wire::HttpMethod::Tag::Extension) {
|
|
result.extension = bsatn::deserialize<std::string>(reader);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
static AlgebraicType algebraic_type() {
|
|
return AlgebraicType::U8();
|
|
}
|
|
};
|
|
|
|
// HttpVersion enum serialization (simple tag-only enum)
|
|
template<>
|
|
struct bsatn_traits<wire::HttpVersion> {
|
|
static void serialize(Writer& writer, const wire::HttpVersion& value) {
|
|
writer.write_u8(static_cast<uint8_t>(value.tag));
|
|
}
|
|
|
|
static wire::HttpVersion deserialize(Reader& reader) {
|
|
wire::HttpVersion result;
|
|
result.tag = static_cast<wire::HttpVersion::Tag>(reader.read_u8());
|
|
return result;
|
|
}
|
|
|
|
static AlgebraicType algebraic_type() {
|
|
return AlgebraicType::U8();
|
|
}
|
|
};
|
|
|
|
// HttpHeaderPair struct serialization
|
|
template<>
|
|
struct bsatn_traits<wire::HttpHeaderPair> {
|
|
static void serialize(Writer& writer, const wire::HttpHeaderPair& value) {
|
|
// Field 0: name (String)
|
|
bsatn::serialize(writer, value.name);
|
|
// Field 1: value (Vec<u8>)
|
|
bsatn::serialize(writer, value.value);
|
|
}
|
|
|
|
static wire::HttpHeaderPair deserialize(Reader& reader) {
|
|
wire::HttpHeaderPair result;
|
|
// Field 0: name
|
|
result.name = bsatn::deserialize<std::string>(reader);
|
|
// Field 1: value
|
|
result.value = bsatn::deserialize<std::vector<uint8_t>>(reader);
|
|
return result;
|
|
}
|
|
|
|
static AlgebraicType algebraic_type() {
|
|
ProductTypeBuilder builder;
|
|
builder.with_field<std::string>("name");
|
|
builder.with_field<std::vector<uint8_t>>("value");
|
|
return AlgebraicType::make_product(builder.build());
|
|
}
|
|
};
|
|
|
|
// HttpHeaders struct serialization
|
|
template<>
|
|
struct bsatn_traits<wire::HttpHeaders> {
|
|
static void serialize(Writer& writer, const wire::HttpHeaders& value) {
|
|
// Field 0: entries (Vec<HttpHeaderPair>)
|
|
bsatn::serialize(writer, value.entries);
|
|
}
|
|
|
|
static wire::HttpHeaders deserialize(Reader& reader) {
|
|
wire::HttpHeaders result;
|
|
// Field 0: entries
|
|
result.entries = bsatn::deserialize<std::vector<wire::HttpHeaderPair>>(reader);
|
|
return result;
|
|
}
|
|
|
|
static AlgebraicType algebraic_type() {
|
|
ProductTypeBuilder builder;
|
|
builder.with_field<std::vector<wire::HttpHeaderPair>>("entries");
|
|
return AlgebraicType::make_product(builder.build());
|
|
}
|
|
};
|
|
|
|
// HttpRequest struct serialization
|
|
template<>
|
|
struct bsatn_traits<wire::HttpRequest> {
|
|
static void serialize(Writer& writer, const wire::HttpRequest& value) {
|
|
// Field 0: method
|
|
bsatn::serialize(writer, value.method);
|
|
// Field 1: headers
|
|
bsatn::serialize(writer, value.headers);
|
|
// Field 2: timeout
|
|
bsatn::serialize(writer, value.timeout);
|
|
// Field 3: uri
|
|
bsatn::serialize(writer, value.uri);
|
|
// Field 4: version
|
|
bsatn::serialize(writer, value.version);
|
|
}
|
|
|
|
static wire::HttpRequest deserialize(Reader& reader) {
|
|
wire::HttpRequest result;
|
|
// Field 0: method
|
|
result.method = bsatn::deserialize<wire::HttpMethod>(reader);
|
|
// Field 1: headers
|
|
result.headers = bsatn::deserialize<wire::HttpHeaders>(reader);
|
|
// Field 2: timeout
|
|
result.timeout = bsatn::deserialize<std::optional<TimeDuration>>(reader);
|
|
// Field 3: uri
|
|
result.uri = bsatn::deserialize<std::string>(reader);
|
|
// Field 4: version
|
|
result.version = bsatn::deserialize<wire::HttpVersion>(reader);
|
|
return result;
|
|
}
|
|
|
|
static AlgebraicType algebraic_type() {
|
|
ProductTypeBuilder builder;
|
|
builder.with_field<wire::HttpMethod>("method");
|
|
builder.with_field<wire::HttpHeaders>("headers");
|
|
builder.with_field<std::optional<TimeDuration>>("timeout");
|
|
builder.with_field<std::string>("uri");
|
|
builder.with_field<wire::HttpVersion>("version");
|
|
return AlgebraicType::make_product(builder.build());
|
|
}
|
|
};
|
|
|
|
// HttpResponse struct serialization
|
|
template<>
|
|
struct bsatn_traits<wire::HttpResponse> {
|
|
static void serialize(Writer& writer, const wire::HttpResponse& value) {
|
|
// Field 0: headers
|
|
bsatn::serialize(writer, value.headers);
|
|
// Field 1: version
|
|
bsatn::serialize(writer, value.version);
|
|
// Field 2: code
|
|
bsatn::serialize(writer, value.code);
|
|
}
|
|
|
|
static wire::HttpResponse deserialize(Reader& reader) {
|
|
wire::HttpResponse result;
|
|
// Field 0: headers
|
|
result.headers = bsatn::deserialize<wire::HttpHeaders>(reader);
|
|
// Field 1: version
|
|
result.version = bsatn::deserialize<wire::HttpVersion>(reader);
|
|
// Field 2: code
|
|
result.code = bsatn::deserialize<uint16_t>(reader);
|
|
return result;
|
|
}
|
|
|
|
static AlgebraicType algebraic_type() {
|
|
ProductTypeBuilder builder;
|
|
builder.with_field<wire::HttpHeaders>("headers");
|
|
builder.with_field<wire::HttpVersion>("version");
|
|
builder.with_field<uint16_t>("code");
|
|
return AlgebraicType::make_product(builder.build());
|
|
}
|
|
};
|
|
|
|
} // namespace SpacetimeDB::bsatn
|
|
|
|
#endif // SPACETIMEDB_HTTP_WIRE_H
|