Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/bindings-csharp/BSATN.Runtime/Builtins.cs
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ public readonly TimeDuration TimeDurationSince(Timestamp earlier) =>
public static Timestamp operator -(Timestamp point, TimeDuration interval) =>
new Timestamp(checked(point.MicrosecondsSinceUnixEpoch - interval.Microseconds));

public int CompareTo(Timestamp that)
public readonly int CompareTo(Timestamp that)
{
return this.MicrosecondsSinceUnixEpoch.CompareTo(that.MicrosecondsSinceUnixEpoch);
}
Expand Down
77 changes: 77 additions & 0 deletions crates/bindings-csharp/BSATN.Runtime/HttpWireTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
namespace SpacetimeDB;

using System.ComponentModel;

// NOTE: These types define the stable BSATN wire format for the procedure_http_request ABI.
// They must match `spacetimedb_lib::http::{Request, Response}` exactly (field order + types),
// because the host BSATN-decodes these bytes directly and may trap on mismatch.
// Do not reorder fields or extend these types; add a new versioned ABI instead.

[Type]
[EditorBrowsable(EditorBrowsableState.Never)]
public partial record HttpMethodWire
: TaggedEnum<(
Unit Get,
Unit Head,
Unit Post,
Unit Put,
Unit Delete,
Unit Connect,
Unit Options,
Unit Trace,
Unit Patch,
string Extension
)>;

[Type]
[EditorBrowsable(EditorBrowsableState.Never)]
public enum HttpVersionWire : byte
{
Http09,
Http10,
Http11,
Http2,
Http3,
}

[Type]
[EditorBrowsable(EditorBrowsableState.Never)]
public partial struct HttpHeaderPairWire
{
public string Name;
public byte[] Value;
}

[Type]
[EditorBrowsable(EditorBrowsableState.Never)]
public partial struct HttpHeadersWire
{
public HttpHeaderPairWire[] Entries;
}

[Type]
[EditorBrowsable(EditorBrowsableState.Never)]
public partial struct HttpTimeoutWire
{
public TimeDuration Timeout;
}

[Type]
[EditorBrowsable(EditorBrowsableState.Never)]
public partial struct HttpRequestWire
{
public HttpMethodWire Method;
public HttpHeadersWire Headers;
public HttpTimeoutWire? Timeout;
public string Uri;
public HttpVersionWire Version;
}

[Type]
[EditorBrowsable(EditorBrowsableState.Never)]
public partial struct HttpResponseWire
{
public HttpHeadersWire Headers;
public HttpVersionWire Version;
public ushort Code;
}
5 changes: 5 additions & 0 deletions crates/bindings-csharp/Runtime/Exceptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ public class TransactionIsMutableException : StdbException
"ABI call can only be made while inside a read-only transaction";
}

public class HttpException : StdbException
{
public override string Message => "HTTP request failed";
}

public class UnknownException : StdbException
{
private readonly Errno code;
Expand Down
261 changes: 261 additions & 0 deletions crates/bindings-csharp/Runtime/Http.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
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,
}

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");
}

// `IsSensitive` is a local-only hint. The current stable HTTP wire format does not carry
// header sensitivity metadata (Rust wire type uses only (name: string, value: bytes)),
// so this flag 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) { }
}

public readonly record struct HttpBody(byte[] Bytes)
{
public static HttpBody Empty => new(Array.Empty<byte>());

public byte[] ToBytes() => Bytes;

public string ToStringUtf8Lossy() => Encoding.UTF8.GetString(Bytes);

public static HttpBody FromString(string s) => new(Encoding.UTF8.GetBytes(s));
}

public sealed class HttpRequest
{
public required string Uri { get; init; }
public HttpMethod Method { get; init; } = HttpMethod.Get;
public List<HttpHeader> Headers { get; init; } = new();
public HttpBody Body { get; init; } = HttpBody.Empty;
public HttpVersion Version { get; init; } = HttpVersion.Http11;
public TimeSpan? Timeout { get; init; }
}

public readonly record struct HttpResponse(
ushort StatusCode,
HttpVersion Version,
List<HttpHeader> Headers,
HttpBody Body
);

public sealed class HttpError(string message) : Exception(message)
{
private readonly string message = message;

public override string Message => message;
}

public sealed class HttpClient
{
private static readonly TimeSpan MaxTimeout = TimeSpan.FromMilliseconds(500);

public Result<HttpResponse, HttpError> Get(string uri, TimeSpan? timeout = null) =>
Send(
new HttpRequest
{
Uri = uri,
Method = HttpMethod.Get,
Body = HttpBody.Empty,
Timeout = timeout,
}
);

public Result<HttpResponse, HttpError> Send(HttpRequest request)

Check warning on line 92 in crates/bindings-csharp/Runtime/Http.cs

View workflow job for this annotation

GitHub Actions / unity-testsuite

Member 'Send' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)

Check warning on line 92 in crates/bindings-csharp/Runtime/Http.cs

View workflow job for this annotation

GitHub Actions / Test Suite

Member 'Send' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)

Check warning on line 92 in crates/bindings-csharp/Runtime/Http.cs

View workflow job for this annotation

GitHub Actions / csharp-testsuite

Member 'Send' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
{
// 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<HttpResponse, HttpError>.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<HttpResponse, HttpError>.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<HttpResponse, HttpError>.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<HttpResponse, HttpError>.Err(new HttpError(err));
}
case Errno.WOULD_BLOCK_TRANSACTION:
return Result<HttpResponse, HttpError>.Err(
new HttpError(
"HTTP requests cannot be performed while a mutable transaction is open (WOULD_BLOCK_TRANSACTION)."
)
);
default:
return Result<HttpResponse, HttpError>.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<HttpResponse, HttpError>.Err(new HttpError(ex.ToString()));
}
}

private static T FromBytes<T>(IReadWrite<T> 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<HttpHeader> 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);
}
}
18 changes: 18 additions & 0 deletions crates/bindings-csharp/Runtime/Internal/FFI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public enum Errno : short
TRANSACTION_NOT_ANONYMOUS = 18,
TRANSACTION_IS_READ_ONLY = 19,
TRANSACTION_IS_MUT = 20,
HTTP_ERROR = 21,
}

#pragma warning disable IDE1006 // Naming Styles - Not applicable to FFI stuff.
Expand Down Expand Up @@ -144,6 +145,7 @@ public static Exception ToException(Errno status) =>
Errno.TRANSACTION_NOT_ANONYMOUS => new TransactionNotAnonymousException(),
Errno.TRANSACTION_IS_READ_ONLY => new TransactionIsReadOnlyException(),
Errno.TRANSACTION_IS_MUT => new TransactionIsMutableException(),
Errno.HTTP_ERROR => new HttpException(),
_ => new UnknownException(status),
};
}
Expand Down Expand Up @@ -379,4 +381,20 @@ uint args_len

[LibraryImport(StdbNamespace10_3, EntryPoint = "procedure_abort_mut_tx")]
public static partial Errno procedure_abort_mut_tx();

[StructLayout(LayoutKind.Sequential)]
public readonly struct BytesSourcePair
{
public readonly BytesSource A;
public readonly BytesSource B;
}

[LibraryImport(StdbNamespace10_3, EntryPoint = "procedure_http_request")]
public static partial Errno procedure_http_request(
ReadOnlySpan<byte> request,
uint request_len,
ReadOnlySpan<byte> body,
uint body_len,
out BytesSourcePair out_
);
}
Loading
Loading