namespace SpacetimeDB;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Internal;
using SpacetimeDB.BSATN;
public enum HttpVersion : byte
{
Http09,
Http10,
Http11,
Http2,
Http3,
}
///
/// Represents an HTTP method (e.g. GET, POST).
///
///
/// Unknown methods are supported by providing an arbitrary string value.
///
public readonly record struct HttpMethod(string Value)
{
public static readonly HttpMethod Get = new("GET");
public static readonly HttpMethod Head = new("HEAD");
public static readonly HttpMethod Post = new("POST");
public static readonly HttpMethod Put = new("PUT");
public static readonly HttpMethod Delete = new("DELETE");
public static readonly HttpMethod Connect = new("CONNECT");
public static readonly HttpMethod Options = new("OPTIONS");
public static readonly HttpMethod Trace = new("TRACE");
public static readonly HttpMethod Patch = new("PATCH");
}
///
/// Represents an HTTP header name/value pair.
///
///
/// Multiple headers with the same name are permitted.
/// The IsSensitive flag is a local-only hint and is not transmitted to the host.
///
public readonly record struct HttpHeader(string Name, byte[] Value, bool IsSensitive = false)
{
public HttpHeader(string name, string value)
: this(name, Encoding.ASCII.GetBytes(value), false) { }
}
///
/// Represents the body of an HTTP request or response.
///
///
/// Bodies are treated as raw bytes. Use when interpreting a body as UTF-8 text.
///
public readonly record struct HttpBody(byte[] Bytes)
{
public static HttpBody Empty => new(Array.Empty());
public byte[] ToBytes() => Bytes;
public string ToStringUtf8Lossy() => Encoding.UTF8.GetString(Bytes);
public static HttpBody FromString(string s) => new(Encoding.UTF8.GetBytes(s));
}
///
/// Represents an HTTP request to be executed by the SpacetimeDB host from within a procedure.
///
///
/// The request body is stored separately from the request metadata in the host ABI.
///
public sealed class HttpRequest
{
/// Request URI.
/// Must not be null or empty.
public required string Uri { get; init; }
/// HTTP method to use (e.g. GET, POST).
public HttpMethod Method { get; init; } = HttpMethod.Get;
/// HTTP headers to include with the request.
public List Headers { get; init; } = new();
/// Request body bytes.
public HttpBody Body { get; init; } = HttpBody.Empty;
/// HTTP version to report in the request metadata.
public HttpVersion Version { get; init; } = HttpVersion.Http11;
///
/// Optional timeout for the request.
///
///
/// The SpacetimeDB host clamps all timeouts to a maximum of 500ms.
///
public TimeSpan? Timeout { get; init; }
}
///
/// Represents an HTTP response returned by the SpacetimeDB host.
///
///
/// A non-2xx status code is still returned as a successful response; callers should inspect
/// to handle application-level errors from the remote server.
///
public readonly record struct HttpResponse(
ushort StatusCode,
HttpVersion Version,
List Headers,
HttpBody Body
);
///
/// Error returned when the SpacetimeDB host could not execute an HTTP request.
///
///
/// This indicates a failure to perform the request (e.g. DNS failure, connection error, timeout),
/// not an application-level HTTP error response (which is represented by ).
///
public sealed class HttpError(string message) : Exception(message)
{
private readonly string message = message;
public override string Message => message;
}
///
/// Allows a procedure to perform outbound HTTP requests via the host.
///
///
/// This API is available from ProcedureContext.Http.
///
/// The request metadata (method/headers/timeout/uri/version) is encoded using a stable wire format
/// and executed by the SpacetimeDB host. The request body is sent separately as raw bytes.
///
///
/// Transaction limitation: HTTP requests cannot be performed while a mutable transaction is open.
/// If called inside WithTx, the host will reject the call (WOULD_BLOCK_TRANSACTION).
///
///
///
/// Timeouts: The host clamps all HTTP timeouts to a maximum of 500ms.
///
///
///
/// The returned response may have any HTTP status code (including non-2xx). This is still considered a
/// successful HTTP exchange; only returns an error when the request could not be
/// initiated or completed (e.g. DNS failure, connection failure, timeout).
///
///
public sealed class HttpClient
{
private static readonly TimeSpan MaxTimeout = TimeSpan.FromMilliseconds(500);
///
/// Send a simple GET request to with no headers.
///
/// The request URI.
///
/// Optional timeout for the request. The host clamps timeouts to a maximum of 500ms.
///
///
/// Ok(HttpResponse) when a response was received (regardless of HTTP status code),
/// or Err(HttpError) if the request failed to execute.
///
///
///
/// [SpacetimeDB.Procedure]
/// public static string FetchSchema(ProcedureContext ctx)
/// {
/// var result = ctx.Http.Get("http://localhost:3000/v1/database/schema");
/// if (!result.IsSuccess)
/// {
/// return $"ERR {result.Error}";
/// }
///
/// var response = result.Value!;
/// return response.Body.ToStringUtf8Lossy();
/// }
///
///
public Result Get(string uri, TimeSpan? timeout = null) =>
Send(
new HttpRequest
{
Uri = uri,
Method = HttpMethod.Get,
Body = HttpBody.Empty,
Timeout = timeout,
}
);
///
/// Send an HTTP request described by and wait for its response.
///
///
/// Request metadata (method, headers, uri, version, optional timeout) plus a request body.
///
///
/// Ok(HttpResponse) when a response was received (including non-2xx status codes),
/// or Err(HttpError) when the host could not perform the request.
///
///
/// This method does not throw for expected failures; errors are returned as Result.Err.
///
///
///
/// [SpacetimeDB.Procedure]
/// public static string PostSomething(ProcedureContext ctx)
/// {
/// var request = new HttpRequest
/// {
/// Uri = "https://some-remote-host.invalid/upload",
/// Method = new HttpMethod("POST"),
/// Headers = new()
/// {
/// new HttpHeader("Content-Type", "text/plain"),
/// },
/// Body = HttpBody.FromString("This is the body of the HTTP request"),
/// Timeout = TimeSpan.FromMilliseconds(100),
/// };
///
/// var result = ctx.Http.Send(request);
/// if (!result.IsSuccess)
/// {
/// return $"ERR {result.Error}";
/// }
///
/// var response = result.Value!;
/// return $"OK status={response.StatusCode} body={response.Body.ToStringUtf8Lossy()}";
/// }
///
///
///
///
/// [SpacetimeDB.Procedure]
/// public static string FetchMay404(ProcedureContext ctx)
/// {
/// var result = ctx.Http.Get("https://example.invalid/missing");
/// if (!result.IsSuccess)
/// {
/// // DNS failure, connection drop, timeout, etc.
/// return $"ERR transport: {result.Error}";
/// }
///
/// var response = result.Value!;
/// if (response.StatusCode != 200)
/// {
/// // Application-level HTTP error response.
/// return $"ERR http status={response.StatusCode}";
/// }
///
/// return $"OK {response.Body.ToStringUtf8Lossy()}";
/// }
///
///
///
///
/// [SpacetimeDB.Procedure]
/// public static void DontDoThis(ProcedureContext ctx)
/// {
/// ctx.WithTx(tx =>
/// {
/// // The host rejects this with WOULD_BLOCK_TRANSACTION.
/// var _ = ctx.Http.Get("https://example.invalid/");
/// return 0;
/// });
/// }
///
///
public Result Send(HttpRequest request)
{
// The host syscall expects BSATN-encoded spacetimedb_lib::http::Request bytes.
// A mismatch in the wire layout can cause the host to trap during BSATN decode,
// so the C# `Http*Wire` types must remain in lockstep with the Rust definitions.
try
{
if (string.IsNullOrEmpty(request.Uri))
{
return Result.Err(
new HttpError("URI must not be null or empty")
);
}
// The host clamps all HTTP timeouts to a maximum of 500ms.
// Clamp here as well to keep C# behavior aligned with the Rust docs and to reduce surprises.
var timeout = request.Timeout;
if (timeout is not null)
{
if (timeout.Value < TimeSpan.Zero)
{
return Result.Err(
new HttpError("Timeout must not be negative")
);
}
if (timeout.Value > MaxTimeout)
{
timeout = MaxTimeout;
}
}
var requestWire = new HttpRequestWire
{
Method = ToWireMethod(request.Method),
Headers = new HttpHeadersWire
{
Entries = request.Headers.Select(ToWireHeader).ToArray(),
},
Timeout = timeout is null
? null
: new HttpTimeoutWire { Timeout = (TimeDuration)timeout.Value },
Uri = request.Uri,
Version = ToWireVersion(request.Version),
};
var requestBytes = IStructuralReadWrite.ToBytes(
new HttpRequestWire.BSATN(),
requestWire
);
var bodyBytes = request.Body.ToBytes();
var status = FFI.procedure_http_request(
requestBytes,
(uint)requestBytes.Length,
bodyBytes,
(uint)bodyBytes.Length,
out var out_
);
switch (status)
{
case Errno.OK:
{
var responseWireBytes = out_.A.Consume();
var responseWire = FromBytes(new HttpResponseWire.BSATN(), responseWireBytes);
var body = new HttpBody(out_.B.Consume());
var (statusCode, version, headers) = FromWireResponse(responseWire);
return Result.Ok(
new HttpResponse(statusCode, version, headers, body)
);
}
case Errno.HTTP_ERROR:
{
var errorWireBytes = out_.A.Consume();
var err = FromBytes(new SpacetimeDB.BSATN.String(), errorWireBytes);
return Result.Err(new HttpError(err));
}
case Errno.WOULD_BLOCK_TRANSACTION:
return Result.Err(
new HttpError(
"HTTP requests cannot be performed while a mutable transaction is open (WOULD_BLOCK_TRANSACTION)."
)
);
default:
return Result.Err(
new HttpError(FFI.ErrnoHelpers.ToException(status).ToString())
);
}
}
// Important: avoid throwing across the procedure boundary.
// Throwing here would trap the module (and fail the whole procedure invocation).
// Convert all unexpected failures (including decode errors / unexpected errno) into Result.Err instead.
catch (Exception ex)
{
return Result.Err(new HttpError(ex.ToString()));
}
}
private static T FromBytes(IReadWrite rw, byte[] bytes)
{
using var ms = new MemoryStream(bytes);
using var reader = new BinaryReader(ms);
var value = rw.Read(reader);
if (ms.Position != ms.Length)
{
throw new InvalidOperationException(
"Unrecognized extra bytes while decoding BSATN value"
);
}
return value;
}
private static HttpMethodWire ToWireMethod(HttpMethod method)
{
var m = method.Value;
return m switch
{
"GET" => new HttpMethodWire.Get(default),
"HEAD" => new HttpMethodWire.Head(default),
"POST" => new HttpMethodWire.Post(default),
"PUT" => new HttpMethodWire.Put(default),
"DELETE" => new HttpMethodWire.Delete(default),
"CONNECT" => new HttpMethodWire.Connect(default),
"OPTIONS" => new HttpMethodWire.Options(default),
"TRACE" => new HttpMethodWire.Trace(default),
"PATCH" => new HttpMethodWire.Patch(default),
_ => new HttpMethodWire.Extension(m),
};
}
private static HttpVersionWire ToWireVersion(HttpVersion version) =>
version switch
{
HttpVersion.Http09 => HttpVersionWire.Http09,
HttpVersion.Http10 => HttpVersionWire.Http10,
HttpVersion.Http11 => HttpVersionWire.Http11,
HttpVersion.Http2 => HttpVersionWire.Http2,
HttpVersion.Http3 => HttpVersionWire.Http3,
_ => throw new ArgumentOutOfRangeException(nameof(version)),
};
private static HttpHeaderPairWire ToWireHeader(HttpHeader header) =>
new() { Name = header.Name, Value = header.Value };
private static (
ushort statusCode,
HttpVersion version,
List headers
) FromWireResponse(HttpResponseWire responseWire)
{
var version = responseWire.Version switch
{
HttpVersionWire.Http09 => HttpVersion.Http09,
HttpVersionWire.Http10 => HttpVersion.Http10,
HttpVersionWire.Http11 => HttpVersion.Http11,
HttpVersionWire.Http2 => HttpVersion.Http2,
HttpVersionWire.Http3 => HttpVersion.Http3,
_ => throw new InvalidOperationException("Invalid HTTP version returned from host"),
};
var headers = responseWire
.Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false))
.ToList();
return (responseWire.Code, version, headers);
}
}