From 116ba60a9316de105ace8b2da3f5ed4377c5906a Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Fri, 1 May 2026 11:11:17 -0700 Subject: [PATCH 1/5] Initial wiring for C# http handlers mirrored from Rust --- .../BSATN.Runtime/HttpWireTypes.cs | 16 + crates/bindings-csharp/Codegen/Diag.cs | 48 +++ crates/bindings-csharp/Codegen/Module.cs | 363 ++++++++++++++++-- .../Runtime.Tests/RouterTests.cs | 79 ++++ crates/bindings-csharp/Runtime/Attrs.cs | 9 + .../bindings-csharp/Runtime/HandlerContext.cs | 238 ++++++++++++ crates/bindings-csharp/Runtime/Http.cs | 59 ++- .../Runtime/Internal/IHttpHandler.cs | 11 + .../Runtime/Internal/Module.cs | 74 ++++ crates/bindings-csharp/Runtime/Router.cs | 151 ++++++++ crates/bindings-csharp/Runtime/bindings.c | 28 +- 11 files changed, 1034 insertions(+), 42 deletions(-) create mode 100644 crates/bindings-csharp/Runtime.Tests/RouterTests.cs create mode 100644 crates/bindings-csharp/Runtime/HandlerContext.cs create mode 100644 crates/bindings-csharp/Runtime/Internal/IHttpHandler.cs create mode 100644 crates/bindings-csharp/Runtime/Router.cs diff --git a/crates/bindings-csharp/BSATN.Runtime/HttpWireTypes.cs b/crates/bindings-csharp/BSATN.Runtime/HttpWireTypes.cs index b0efb0c3d4a..91aa4e22963 100644 --- a/crates/bindings-csharp/BSATN.Runtime/HttpWireTypes.cs +++ b/crates/bindings-csharp/BSATN.Runtime/HttpWireTypes.cs @@ -75,3 +75,19 @@ public partial struct HttpResponseWire public HttpVersionWire Version; public ushort Code; } + +[Type] +[EditorBrowsable(EditorBrowsableState.Never)] +public partial struct HttpRequestAndBodyWire +{ + public HttpRequestWire Request; + public byte[] Body; +} + +[Type] +[EditorBrowsable(EditorBrowsableState.Never)] +public partial struct HttpResponseAndBodyWire +{ + public HttpResponseWire Response; + public byte[] Body; +} diff --git a/crates/bindings-csharp/Codegen/Diag.cs b/crates/bindings-csharp/Codegen/Diag.cs index 383fe058448..81375217b8f 100644 --- a/crates/bindings-csharp/Codegen/Diag.cs +++ b/crates/bindings-csharp/Codegen/Diag.cs @@ -267,4 +267,52 @@ string typeName $"Index attribute on a table declaration must specify Accessor. Field-level index attributes may omit Accessor and default to the field name.", attr => attr ); + + public static readonly ErrorDescriptor HttpHandlerContextParam = + new( + group, + "HTTP handlers must have a first argument of type HandlerContext", + method => + $"HTTP handler method {method.Identifier} does not have a HandlerContext parameter.", + method => method.ParameterList + ); + + public static readonly ErrorDescriptor HttpHandlerRequestParam = + new( + group, + "HTTP handlers must have a second argument of type HttpRequest", + method => + $"HTTP handler method {method.Identifier} does not have an HttpRequest parameter.", + method => method.ParameterList + ); + + public static readonly ErrorDescriptor HttpHandlerReturnType = + new( + group, + "HTTP handlers must return HttpResponse", + method => + $"HTTP handler method {method.Identifier} returns {method.ReturnType} instead of HttpResponse.", + method => method.ReturnType + ); + + public static readonly ErrorDescriptor HttpRouterSignature = + new( + group, + "HTTP routers must be static parameterless methods returning Router", + method => + $"HTTP router method {method.Identifier} must be static, parameterless, and return Router.", + method => method + ); + + public static readonly ErrorDescriptor<( + MethodDeclarationSyntax method, + string prefix + )> HttpHandlerReservedPrefix = + new( + group, + "HTTP handler method has a reserved name prefix", + ctx => + $"HTTP handler method {ctx.method.Identifier} starts with '{ctx.prefix}', which is a reserved prefix.", + ctx => ctx.method.Identifier + ); } diff --git a/crates/bindings-csharp/Codegen/Module.cs b/crates/bindings-csharp/Codegen/Module.cs index e778b6d69cc..5f91a9e4971 100644 --- a/crates/bindings-csharp/Codegen/Module.cs +++ b/crates/bindings-csharp/Codegen/Module.cs @@ -1750,6 +1750,111 @@ public Scope.Extensions GenerateSchedule() } } +record HttpHandlerDeclaration +{ + public readonly string Name; + public readonly string? CanonicalName; + public readonly string FullName; + private readonly bool HasWrongSignature; + + public string Identifier => EscapeIdentifier(Name); + + public HttpHandlerDeclaration(GeneratorAttributeSyntaxContext context, DiagReporter diag) + { + var methodSyntax = (MethodDeclarationSyntax)context.TargetNode; + var method = (IMethodSymbol)context.TargetSymbol; + var attr = context.Attributes.Single().ParseAs(); + + if ( + method.Parameters.FirstOrDefault()?.Type + is not INamedTypeSymbol { Name: "HandlerContext" } + ) + { + diag.Report(ErrorDescriptor.HttpHandlerContextParam, methodSyntax); + HasWrongSignature = true; + } + + if ( + method.Parameters.ElementAtOrDefault(1)?.Type + is not INamedTypeSymbol { Name: "HttpRequest" } + ) + { + diag.Report(ErrorDescriptor.HttpHandlerRequestParam, methodSyntax); + HasWrongSignature = true; + } + + if ( + method.ReturnType is not INamedTypeSymbol { Name: "HttpResponse" } + ) + { + diag.Report(ErrorDescriptor.HttpHandlerReturnType, methodSyntax); + HasWrongSignature = true; + } + + Name = method.Name; + if (Name.Length >= 2) + { + var prefix = Name[..2]; + if (prefix is "__" or "on" or "On") + { + diag.Report(ErrorDescriptor.HttpHandlerReservedPrefix, (methodSyntax, prefix)); + } + } + + CanonicalName = attr.Name; + FullName = SymbolToName(method); + } + + public string GenerateClass() + { + var invocation = HasWrongSignature + ? "throw new System.InvalidOperationException(\"Invalid HTTP handler signature.\")" + : $"{FullName}((SpacetimeDB.HandlerContext)ctx, request)"; + + return $$""" + class {{Identifier}} : SpacetimeDB.Internal.IHttpHandler { + public SpacetimeDB.Internal.RawHttpHandlerDefV10 MakeHandlerDef() => new( + SourceName: nameof({{Identifier}}) + ); + + public SpacetimeDB.HttpResponse Invoke( + SpacetimeDB.HandlerContextBase ctx, + SpacetimeDB.HttpRequest request + ) { + return {{invocation}}; + } + } + """; + } +} + +record HttpRouterDeclaration +{ + public readonly string FullName; + public readonly bool IsValid; + + public HttpRouterDeclaration(GeneratorAttributeSyntaxContext context, DiagReporter diag) + { + var methodSyntax = (MethodDeclarationSyntax)context.TargetNode; + var method = (IMethodSymbol)context.TargetSymbol; + + if ( + !method.IsStatic + || method.Parameters.Length != 0 + || method.ReturnType is not INamedTypeSymbol { Name: "Router" } + ) + { + diag.Report(ErrorDescriptor.HttpRouterSignature, methodSyntax); + } + else + { + IsValid = true; + } + + FullName = SymbolToName(method); + } +} + record ClientVisibilityFilterDeclaration { public readonly string FullName; @@ -1859,6 +1964,99 @@ Func toFullName .WithTrackingName($"SpacetimeDB.{kind}.Collect"); } + private static ( + TTableAccessors tableAccessors, + TSettings settings, + TTableDecls tableDecls, + TReducers addReducers, + TProcedures addProcedures, + THttpHandlers addHttpHandlers, + TReadOnlyAccessors readOnlyAccessors, + THttpRouters httpRouters, + TViews views, + TRlsFilters rlsFilters, + TColumnDefaultValues columnDefaultValues + ) FlattenModuleOutputInputs< + TTableAccessors, + TSettings, + TTableDecls, + TReducers, + TProcedures, + THttpHandlers, + TReadOnlyAccessors, + THttpRouters, + TViews, + TRlsFilters, + TColumnDefaultValues + >( + ( + ( + ( + ( + ( + ( + ( + ( + ( + (TTableAccessors, TSettings), + TTableDecls + ), + TReducers + ), + TProcedures + ), + THttpHandlers + ), + TReadOnlyAccessors + ), + THttpRouters + ), + TViews + ), + TRlsFilters + ), + TColumnDefaultValues + ) tuple + ) + { + var ( + ( + ( + ( + ( + ( + ( + (((tableAccessors, settings), tableDecls), addReducers), + addProcedures + ), + addHttpHandlers + ), + readOnlyAccessors + ), + httpRouters + ), + views + ), + rlsFilters + ), + columnDefaultValues + ) = tuple; + + return ( + tableAccessors, + settings, + tableDecls, + addReducers, + addProcedures, + addHttpHandlers, + readOnlyAccessors, + httpRouters, + views, + rlsFilters, + columnDefaultValues + ); + } + public void Initialize(IncrementalGeneratorInitializationContext context) { var settings = context @@ -1987,6 +2185,42 @@ public void Initialize(IncrementalGeneratorInitializationContext context) p => p.FullName ); + var httpHandlers = context + .SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: typeof(HttpHandlerAttribute).FullName, + predicate: (node, ct) => true, + transform: (context, ct) => + context.ParseWithDiags(diag => new HttpHandlerDeclaration(context, diag)) + ) + .ReportDiagnostics(context) + .WithTrackingName("SpacetimeDB.HttpHandler.Parse"); + + var addHttpHandlers = CollectDistinct( + "HttpHandler", + context, + httpHandlers + .Select((h, ct) => (h.Name, h.FullName, h.CanonicalName, Class: h.GenerateClass())) + .WithTrackingName("SpacetimeDB.HttpHandler.GenerateClass"), + h => h.Name, + h => h.FullName + ); + + var httpRouters = CollectDistinct( + "HttpRouter", + context, + context + .SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: typeof(HttpRouterAttribute).FullName, + predicate: (node, ct) => true, + transform: (context, ct) => + context.ParseWithDiags(diag => new HttpRouterDeclaration(context, diag)) + ) + .ReportDiagnostics(context) + .WithTrackingName("SpacetimeDB.HttpRouter.Parse"), + r => r.FullName, + r => r.FullName + ); + var tableAccessors = CollectDistinct( "Table", context, @@ -2040,36 +2274,38 @@ public void Initialize(IncrementalGeneratorInitializationContext context) v => v.tableName + "_" + v.columnId ); + var moduleOutputInputs = tableAccessors + .Combine(settingsArray) + .Combine(tableDecls) + .Combine(addReducers) + .Combine(addProcedures) + .Combine(addHttpHandlers) + .Combine(readOnlyAccessors) + .Combine(httpRouters) + .Combine(views) + .Combine(rlsFiltersArray) + .Combine(columnDefaultValues) + .Select((tuple, ct) => FlattenModuleOutputInputs(tuple)); + // Register the generated source code with the compilation context as part of module publishing // Once the compilation is complete, the generated code will be used to create tables and reducers in the database context.RegisterSourceOutput( - tableAccessors - .Combine(settingsArray) - .Combine(tableDecls) - .Combine(addReducers) - .Combine(addProcedures) - .Combine(readOnlyAccessors) - .Combine(views) - .Combine(rlsFiltersArray) - .Combine(columnDefaultValues), - (context, tuple) => + moduleOutputInputs, + (context, inputs) => { var ( - ( - ( - ( - ( - (((tableAccessors, settings), tableDecls), addReducers), - addProcedures - ), - readOnlyAccessors - ), - views - ), - rlsFilters - ), + tableAccessors, + settings, + tableDecls, + addReducers, + addProcedures, + addHttpHandlers, + readOnlyAccessors, + httpRouters, + views, + rlsFilters, columnDefaultValues - ) = tuple; + ) = inputs; if (settings.Array.Length > 1) { @@ -2110,6 +2346,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) $"SpacetimeDB.Internal.Module.RegisterExplicitFunctionName(\"{EscapeStringLiteral(p.Name)}\", \"{EscapeStringLiteral(p.CanonicalName!)}\");" ) ) + .Concat( + addHttpHandlers + .Array.Where(h => !string.IsNullOrEmpty(h.CanonicalName)) + .Select(h => + $"SpacetimeDB.Internal.Module.RegisterExplicitFunctionName(\"{EscapeStringLiteral(h.Name)}\", \"{EscapeStringLiteral(h.CanonicalName!)}\");" + ) + ) .Concat( views .Array.Where(v => !string.IsNullOrEmpty(v.CanonicalName)) @@ -2158,6 +2401,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) tableAccessors.Array.IsEmpty && addReducers.Array.IsEmpty && addProcedures.Array.IsEmpty + && addHttpHandlers.Array.IsEmpty ) { return; @@ -2323,6 +2567,43 @@ public Uuid NewUuidV7() } } + public sealed partial class HandlerContext : global::SpacetimeDB.HandlerContextBase { + private readonly Local _db = new(); + + internal HandlerContext(Random random, Timestamp time) + : base(random, time) {} + + protected override global::SpacetimeDB.LocalBase CreateLocal() => _db; + protected override global::SpacetimeDB.HandlerTxContextBase CreateTxContext(Internal.TxContext inner) => + _cached ??= new HandlerTxContext(inner); + + private HandlerTxContext? _cached; + + [Experimental("STDB_UNSTABLE")] + public TResult WithTx(Func body) => + base.WithTx(tx => body((HandlerTxContext)tx)); + + [Experimental("STDB_UNSTABLE")] + public TxOutcome TryWithTx( + Func> body) + where TError : Exception => + base.TryWithTx(tx => body((HandlerTxContext)tx)); + + public Uuid NewUuidV4() + { + var bytes = new byte[16]; + Rng.NextBytes(bytes); + return Uuid.FromRandomBytesV4(bytes); + } + + public Uuid NewUuidV7() + { + var bytes = new byte[4]; + Rng.NextBytes(bytes); + return Uuid.FromCounterV7(ref CounterUuid, Timestamp, bytes); + } + } + [Experimental("STDB_UNSTABLE")] public sealed class ProcedureTxContext : global::SpacetimeDB.ProcedureTxContextBase { internal ProcedureTxContext(Internal.TxContext inner) : base(inner) {} @@ -2330,6 +2611,13 @@ internal ProcedureTxContext(Internal.TxContext inner) : base(inner) {} public new Local Db => (Local)base.Db; } + [Experimental("STDB_UNSTABLE")] + public sealed class HandlerTxContext : global::SpacetimeDB.HandlerTxContextBase { + internal HandlerTxContext(Internal.TxContext inner) : base(inner) {} + + public new Local Db => (Local)base.Db; + } + public sealed class Local : global::SpacetimeDB.LocalBase { {{string.Join("\n", tableAccessors.Select(v => v.getter))}} } @@ -2384,6 +2672,8 @@ static class ModuleRegistration { {{string.Join("\n", addProcedures.Select(r => r.Class))}} + {{string.Join("\n", addHttpHandlers.Select(r => r.Class))}} + public static List ToListOrEmpty(T? value) where T : struct => value is null ? new List() : new List { value.Value }; @@ -2403,6 +2693,7 @@ public static void Main() { SpacetimeDB.Internal.Module.SetViewContextConstructor(identity => new SpacetimeDB.ViewContext(identity, new SpacetimeDB.Internal.LocalReadOnly())); SpacetimeDB.Internal.Module.SetAnonymousViewContextConstructor(() => new SpacetimeDB.AnonymousViewContext(new SpacetimeDB.Internal.LocalReadOnly())); SpacetimeDB.Internal.Module.SetProcedureContextConstructor((identity, connectionId, random, time) => new SpacetimeDB.ProcedureContext(identity, connectionId, random, time));{{preRegistrations}} + SpacetimeDB.Internal.Module.SetHandlerContextConstructor((random, time) => new SpacetimeDB.HandlerContext(random, time)); var __memoryStream = new MemoryStream(); var __writer = new BinaryWriter(__memoryStream); @@ -2418,6 +2709,12 @@ public static void Main() { $"SpacetimeDB.Internal.Module.RegisterProcedure<{EscapeIdentifier(r.Name)}>();" ) )}} + {{string.Join( + "\n", + addHttpHandlers.Select(r => + $"SpacetimeDB.Internal.Module.RegisterHttpHandler<{EscapeIdentifier(r.Name)}>();" + ) + )}} // IMPORTANT: The order in which we register views matters. // It must correspond to the order in which we call `GenerateDispatcherClass`. @@ -2435,6 +2732,11 @@ public static void Main() { "\n", tableAccessors.Select(t => $"SpacetimeDB.Internal.Module.RegisterTable<{t.tableName}, SpacetimeDB.Internal.TableHandles.{EscapeIdentifier(t.tableAccessorName)}>();") )}} + {{string.Join( + "\n", + httpRouters.Where(r => r.IsValid) + .Select(r => $"SpacetimeDB.Internal.Module.RegisterHttpRouter({r.FullName}());") + )}} {{string.Join( "\n", rlsFilters.Select(f => $"SpacetimeDB.Internal.Module.RegisterClientVisibilityFilter({f.GlobalName});") @@ -2507,6 +2809,19 @@ SpacetimeDB.Internal.BytesSink result_sink args, result_sink ); + + [UnmanagedCallersOnly(EntryPoint = "__call_http_handler__")] + public static SpacetimeDB.Internal.Errno __call_http_handler__( + uint id, + SpacetimeDB.Timestamp timestamp, + SpacetimeDB.Internal.BytesSource request, + SpacetimeDB.Internal.BytesSink result_sink + ) => SpacetimeDB.Internal.Module.__call_http_handler__( + id, + timestamp, + request, + result_sink + ); [UnmanagedCallersOnly(EntryPoint = "__call_view__")] public static SpacetimeDB.Internal.Errno __call_view__( diff --git a/crates/bindings-csharp/Runtime.Tests/RouterTests.cs b/crates/bindings-csharp/Runtime.Tests/RouterTests.cs new file mode 100644 index 00000000000..f745b02606c --- /dev/null +++ b/crates/bindings-csharp/Runtime.Tests/RouterTests.cs @@ -0,0 +1,79 @@ +namespace Runtime.Tests; + +using SpacetimeDB; + +public class RouterTests +{ + [Fact] + public void AllowsDistinctMethodsOnSamePath() + { + var router = Router.New() + .Get("/hooks", nameof(GetHandler)) + .Post("/hooks", nameof(PostHandler)); + + Assert.NotNull(router); + } + + [Fact] + public void RejectsAnyConflictOnSamePath() + { + var ex = Assert.Throws( + () => Router.New().Any("/hooks", nameof(GetHandler)).Get("/hooks", nameof(PostHandler)) + ); + + Assert.Contains("Route conflict", ex.Message); + } + + [Fact] + public void RejectsInvalidPathCharacters() + { + var ex = Assert.Throws( + () => Router.New().Get("/Bad", nameof(GetHandler)) + ); + + Assert.Contains("Route paths may contain only", ex.Message); + } + + [Fact] + public void NestJoinsPathsWithoutDoubleSlash() + { + var router = Router.New().Nest("/api", Router.New().Get("/hooks", nameof(GetHandler))); + + Assert.NotNull(router); + } + + [Fact] + public void NestAllowsExistingSiblingPrefix() + { + var router = Router.New() + .Get("/apiv2", nameof(GetHandler)) + .Nest("/api", Router.New().Get("/hooks", nameof(PostHandler))); + + Assert.NotNull(router); + } + + [Fact] + public void NestAllowsExistingRouteAtNestedPrefix() + { + var router = Router.New() + .Get("/api", nameof(GetHandler)) + .Nest("/api", Router.New().Get("/hooks", nameof(PostHandler))); + + Assert.NotNull(router); + } + + [Fact] + public void NestStillRejectsExactRouteConflicts() + { + var ex = Assert.Throws( + () => Router.New().Get("/api/hooks", nameof(GetHandler)) + .Nest("/api", Router.New().Get("/hooks", nameof(PostHandler))) + ); + + Assert.Contains("Route conflict", ex.Message); + } + + private static void GetHandler() { } + + private static void PostHandler() { } +} diff --git a/crates/bindings-csharp/Runtime/Attrs.cs b/crates/bindings-csharp/Runtime/Attrs.cs index 67fceffebaf..0e71630ee72 100644 --- a/crates/bindings-csharp/Runtime/Attrs.cs +++ b/crates/bindings-csharp/Runtime/Attrs.cs @@ -203,4 +203,13 @@ public sealed class ProcedureAttribute() : Attribute { public string? Name { get; init; } } + + [AttributeUsage(AttributeTargets.Method, Inherited = false)] + public sealed class HttpHandlerAttribute() : Attribute + { + public string? Name { get; init; } + } + + [AttributeUsage(AttributeTargets.Method, Inherited = false)] + public sealed class HttpRouterAttribute() : Attribute { } } diff --git a/crates/bindings-csharp/Runtime/HandlerContext.cs b/crates/bindings-csharp/Runtime/HandlerContext.cs new file mode 100644 index 00000000000..40eafaab79f --- /dev/null +++ b/crates/bindings-csharp/Runtime/HandlerContext.cs @@ -0,0 +1,238 @@ +namespace SpacetimeDB; + +using System; +using System.Diagnostics.CodeAnalysis; + +#pragma warning disable STDB_UNSTABLE +public abstract class HandlerContextBase(Random random, Timestamp time) +{ + public Random Rng { get; } = random; + public Timestamp Timestamp { get; private set; } = time; + + // NOTE: The host rejects procedure HTTP requests while a mut transaction is open + // (WOULD_BLOCK_TRANSACTION). Avoid calling `Http.*` inside WithTx. + public HttpClient Http { get; } = new(); + + // **Note:** must be 0..=u32::MAX + protected int CounterUuid = 0; + + private Internal.TxContext? txContext; + private HandlerTxContextBase? cachedUserTxContext; + + protected abstract HandlerTxContextBase CreateTxContext(Internal.TxContext inner); + protected internal abstract LocalBase CreateLocal(); + + private protected HandlerTxContextBase RequireTxContext() + { + var inner = + txContext + ?? throw new InvalidOperationException("Transaction context was not initialised."); + cachedUserTxContext ??= CreateTxContext(inner); + cachedUserTxContext.Refresh(inner); + return cachedUserTxContext; + } + + public Internal.TxContext EnterTxContext(long timestampMicros) + { + var timestamp = new Timestamp(timestampMicros); + Timestamp = timestamp; + txContext = + txContext?.WithTimestamp(timestamp) + ?? new Internal.TxContext( + CreateLocal(), + default, + null, + timestamp, + AuthCtx.BuildFromSystemTables(null, default), + Rng + ); + return txContext; + } + + public void ExitTxContext() => txContext = null; + + public readonly struct TxOutcome(bool isSuccess, TResult? value, Exception? error) + { + public bool IsSuccess { get; } = isSuccess; + public TResult? Value { get; } = value; + public Exception? Error { get; } = error; + + public static TxOutcome Success(TResult value) => new(true, value, null); + + public static TxOutcome Failure(Exception error) => new(false, default, error); + + public TResult UnwrapOrThrow() => + IsSuccess + ? Value! + : throw ( + Error + ?? new InvalidOperationException("Transaction failed without an error object.") + ); + } + + [Experimental("STDB_UNSTABLE")] + public TResult WithTx(Func body) => + TryWithTx(tx => Result.Ok(body(tx))).UnwrapOrThrow(); + + [Experimental("STDB_UNSTABLE")] + public TxOutcome TryWithTx( + Func> body + ) + where TError : Exception + { + try + { + var result = RunWithRetry(body); + + return result switch + { + Result.OkR(var value) => TxOutcome.Success(value), + Result.ErrR(var error) => TxOutcome.Failure(error), + _ => throw new InvalidOperationException("Unknown Result variant."), + }; + } + catch (Exception ex) + { + return TxOutcome.Failure(ex); + } + } + + private long StartMutTx() + { + var status = Internal.FFI.procedure_start_mut_tx(out var micros); + Internal.FFI.ErrnoHelpers.ThrowIfError(status); + return micros; + } + + private void CommitMutTx() + { + var status = Internal.FFI.procedure_commit_mut_tx(); + Internal.FFI.ErrnoHelpers.ThrowIfError(status); + } + + private void AbortMutTx() + { + var status = Internal.FFI.procedure_abort_mut_tx(); + Internal.FFI.ErrnoHelpers.ThrowIfError(status); + } + + private bool CommitMutTxWithRetry(Func retryBody) + { + try + { + CommitMutTx(); + return true; + } + catch (TransactionNotAnonymousException) + { + return false; + } + catch (StdbException) + { + Log.Warn("Committing anonymous transaction failed; retrying once."); + if (retryBody()) + { + CommitMutTx(); + return true; + } + return false; + } + } + + private Result RunWithRetry( + Func> body + ) + where TError : Exception + { + var result = RunOnce(body); + if (result is Result.ErrR) + { + return result; + } + + bool Retry() + { + result = RunOnce(body); + return result is Result.OkR; + } + + if (!CommitMutTxWithRetry(Retry)) + { + return result; + } + + return result; + } + + private Result RunOnce( + Func> body + ) + where TError : Exception + { + var micros = StartMutTx(); + using var guard = new AbortGuard(AbortMutTx); + EnterTxContext(micros); + var txCtx = RequireTxContext(); + + Result result = body(txCtx); + + if (result is Result.OkR) + { + guard.Disarm(); + return result; + } + + AbortMutTx(); + guard.Disarm(); + return result; + } + + private sealed class AbortGuard(Action abort) : IDisposable + { + private readonly Action abort = abort; + private bool disarmed; + + public void Disarm() => disarmed = true; + + public void Dispose() + { + if (!disarmed) + { + abort(); + } + } + } +} + +public abstract class HandlerTxContextBase(Internal.TxContext inner) +{ + internal Internal.TxContext Inner { get; private set; } = inner; + + internal void Refresh(Internal.TxContext inner) => Inner = inner; + + public LocalBase Db => (LocalBase)Inner.Db; + public Timestamp Timestamp => Inner.Timestamp; + public Random Rng => Inner.Rng; +} + +internal sealed partial class RuntimeHandlerContext(Random random, Timestamp timestamp) + : HandlerContextBase(random, timestamp) +{ + private readonly RuntimeLocal _db = new(); + + protected internal override LocalBase CreateLocal() => _db; + + protected override HandlerTxContextBase CreateTxContext(Internal.TxContext inner) => + _cached ??= new RuntimeHandlerTxContext(inner); + + private RuntimeHandlerTxContext? _cached; +} + +internal sealed class RuntimeHandlerTxContext : HandlerTxContextBase +{ + internal RuntimeHandlerTxContext(Internal.TxContext inner) + : base(inner) { } + + public new RuntimeLocal Db => (RuntimeLocal)base.Db; +} +#pragma warning restore STDB_UNSTABLE diff --git a/crates/bindings-csharp/Runtime/Http.cs b/crates/bindings-csharp/Runtime/Http.cs index 6d23dc72ef4..44fb40831fd 100644 --- a/crates/bindings-csharp/Runtime/Http.cs +++ b/crates/bindings-csharp/Runtime/Http.cs @@ -418,13 +418,52 @@ private static HttpVersionWire ToWireVersion(HttpVersion 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) + internal static HttpRequest FromWire(HttpRequestAndBodyWire requestAndBodyWire) { - var version = responseWire.Version switch + var requestWire = requestAndBodyWire.Request; + return new HttpRequest + { + Uri = requestWire.Uri, + Method = FromWireMethod(requestWire.Method), + Headers = requestWire.Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)).ToList(), + Body = new HttpBody(requestAndBodyWire.Body), + Version = FromWireVersion(requestWire.Version), + }; + } + + internal static HttpResponseAndBodyWire ToWire(HttpResponse response) => + new() + { + Response = new HttpResponseWire + { + Headers = new HttpHeadersWire + { + Entries = response.Headers.Select(ToWireHeader).ToArray(), + }, + Version = ToWireVersion(response.Version), + Code = response.StatusCode, + }, + Body = response.Body.ToBytes(), + }; + + private static HttpMethod FromWireMethod(HttpMethodWire methodWire) => + methodWire switch + { + HttpMethodWire.Get => HttpMethod.Get, + HttpMethodWire.Head => HttpMethod.Head, + HttpMethodWire.Post => HttpMethod.Post, + HttpMethodWire.Put => HttpMethod.Put, + HttpMethodWire.Delete => HttpMethod.Delete, + HttpMethodWire.Connect => HttpMethod.Connect, + HttpMethodWire.Options => HttpMethod.Options, + HttpMethodWire.Trace => HttpMethod.Trace, + HttpMethodWire.Patch => HttpMethod.Patch, + HttpMethodWire.Extension(var extension) => new HttpMethod(extension), + _ => throw new InvalidOperationException("Invalid HTTP method returned from host"), + }; + + private static HttpVersion FromWireVersion(HttpVersionWire versionWire) => + versionWire switch { HttpVersionWire.Http09 => HttpVersion.Http09, HttpVersionWire.Http10 => HttpVersion.Http10, @@ -434,6 +473,14 @@ List headers _ => throw new InvalidOperationException("Invalid HTTP version returned from host"), }; + private static ( + ushort statusCode, + HttpVersion version, + List headers + ) FromWireResponse(HttpResponseWire responseWire) + { + var version = FromWireVersion(responseWire.Version); + var headers = responseWire .Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)) .ToList(); diff --git a/crates/bindings-csharp/Runtime/Internal/IHttpHandler.cs b/crates/bindings-csharp/Runtime/Internal/IHttpHandler.cs new file mode 100644 index 00000000000..7c920d1bd90 --- /dev/null +++ b/crates/bindings-csharp/Runtime/Internal/IHttpHandler.cs @@ -0,0 +1,11 @@ +namespace SpacetimeDB.Internal; + +public interface IHttpHandler +{ + RawHttpHandlerDefV10 MakeHandlerDef(); + + SpacetimeDB.HttpResponse Invoke( + SpacetimeDB.HandlerContextBase ctx, + SpacetimeDB.HttpRequest request + ); +} diff --git a/crates/bindings-csharp/Runtime/Internal/Module.cs b/crates/bindings-csharp/Runtime/Internal/Module.cs index 8dcf6cebb4e..0518de26841 100644 --- a/crates/bindings-csharp/Runtime/Internal/Module.cs +++ b/crates/bindings-csharp/Runtime/Internal/Module.cs @@ -16,6 +16,8 @@ partial class RawModuleDefV10 private readonly List reducerDefs = []; private readonly List lifecycleReducerDefs = []; private readonly List procedureDefs = []; + private readonly List httpHandlerDefs = []; + private readonly List httpRouteDefs = []; private readonly List viewDefs = []; private readonly List rowLevelSecurityDefs = []; private readonly Dictionary> defaultValuesByTable = @@ -65,6 +67,10 @@ internal void RegisterReducer(RawReducerDefV10 reducer, Lifecycle? lifecycle) internal void RegisterProcedure(RawProcedureDefV10 procedure) => procedureDefs.Add(procedure); + internal void RegisterHttpHandler(RawHttpHandlerDefV10 handler) => httpHandlerDefs.Add(handler); + + internal void RegisterHttpRoute(RawHttpRouteDefV10 route) => httpRouteDefs.Add(route); + internal void RegisterTable(RawTableDefV10 table, RawScheduleDefV10? schedule) { tableDefs.Add(table); @@ -169,6 +175,14 @@ internal RawModuleDefV10 BuildModuleDefinition() { sections.Add(new RawModuleDefV10Section.Procedures(procedureDefs)); } + if (httpHandlerDefs.Count > 0) + { + sections.Add(new RawModuleDefV10Section.HttpHandlers(httpHandlerDefs)); + } + if (httpRouteDefs.Count > 0) + { + sections.Add(new RawModuleDefV10Section.HttpRoutes(httpRouteDefs)); + } if (viewDefs.Count > 0) { sections.Add(new RawModuleDefV10Section.Views(viewDefs)); @@ -210,6 +224,7 @@ public static class Module private static readonly List reducers = []; private static readonly List procedures = []; + private static readonly List httpHandlers = []; private static readonly List viewDispatchers = []; private static readonly List anonymousViewDispatchers = []; @@ -222,6 +237,7 @@ private static Func< >? newReducerContext = null; private static Func? newViewContext = null; private static Func? newAnonymousViewContext = null; + private static Func? newHandlerContext = null; private static Func< Identity, @@ -239,6 +255,10 @@ public static void SetProcedureContextConstructor( Func ctor ) => newProcedureContext = ctor; + public static void SetHandlerContextConstructor( + Func ctor + ) => newHandlerContext = ctor; + public static void SetViewContextConstructor(Func ctor) => newViewContext = ctor; @@ -292,6 +312,28 @@ public static void RegisterProcedure

() moduleDef.RegisterProcedure(procedure.MakeProcedureDef(typeRegistrar)); } + public static void RegisterHttpHandler() + where H : IHttpHandler, new() + { + var handler = new H(); + httpHandlers.Add(handler); + moduleDef.RegisterHttpHandler(handler.MakeHandlerDef()); + } + + public static void RegisterHttpRouter(SpacetimeDB.Router router) + { + foreach (var route in router.GetRoutes()) + { + moduleDef.RegisterHttpRoute( + new RawHttpRouteDefV10( + HandlerFunction: route.HandlerFunction, + Method: route.Method, + Path: route.Path + ) + ); + } + } + public static void RegisterTable() where T : IStructuralReadWrite, new() where View : ITableView, new() @@ -523,6 +565,38 @@ BytesSink resultSink } } + public static Errno __call_http_handler__(uint id, Timestamp timestamp, BytesSource request, BytesSink resultSink) + { + try + { + var random = new Random((int)timestamp.MicrosecondsSinceUnixEpoch); + var time = timestamp.ToStd(); + var ctx = newHandlerContext!(random, time); + + var requestBytes = request.Consume(); + using var stream = new MemoryStream(requestBytes); + using var reader = new BinaryReader(stream); + var requestAndBody = new HttpRequestAndBodyWire.BSATN().Read(reader); + if (stream.Position != stream.Length) + { + throw new Exception("Unrecognised extra bytes in the HTTP handler request"); + } + + var response = httpHandlers[(int)id].Invoke(ctx, SpacetimeDB.HttpClient.FromWire(requestAndBody)); + var responseWire = SpacetimeDB.HttpClient.ToWire(response); + resultSink.Write( + IStructuralReadWrite.ToBytes(new HttpResponseAndBodyWire.BSATN(), responseWire) + ); + + return Errno.OK; + } + catch (Exception e) + { + Log.Error($"Error while invoking HTTP handler: {e}"); + throw; + } + } + ///

/// Called by the host to execute a view when the sender calls the view identified by . /// diff --git a/crates/bindings-csharp/Runtime/Router.cs b/crates/bindings-csharp/Runtime/Router.cs new file mode 100644 index 00000000000..9907f205e3b --- /dev/null +++ b/crates/bindings-csharp/Runtime/Router.cs @@ -0,0 +1,151 @@ +namespace SpacetimeDB; + +using System; +using System.Collections.Generic; +using Internal; + +public sealed class Router +{ + internal readonly record struct RouteSpec(MethodOrAny Method, string Path, string HandlerFunction); + + private const string AcceptableRoutePathCharsHumanDescription = + "ASCII lowercase letters, digits and `-_~/`"; + + private readonly List routes; + + private Router(List routes) + { + this.routes = routes; + } + + public static Router New() => new([]); + + public Router Get(string path, string handlerFunction) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Get(default)), path, handlerFunction); + + public Router Head(string path, string handlerFunction) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Head(default)), path, handlerFunction); + + public Router Options(string path, string handlerFunction) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Options(default)), path, handlerFunction); + + public Router Put(string path, string handlerFunction) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Put(default)), path, handlerFunction); + + public Router Delete(string path, string handlerFunction) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Delete(default)), path, handlerFunction); + + public Router Post(string path, string handlerFunction) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Post(default)), path, handlerFunction); + + public Router Patch(string path, string handlerFunction) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Patch(default)), path, handlerFunction); + + public Router Any(string path, string handlerFunction) => + AddRoute(new MethodOrAny.Any(default), path, handlerFunction); + + public Router Nest(string path, Router subRouter) + { + AssertValidPath(path); + + var merged = CloneRoutes(); + foreach (var route in subRouter.routes) + { + var nestedPath = JoinPaths(path, route.Path); + AddRoute(merged, route.Method, nestedPath, route.HandlerFunction); + } + + return new Router(merged); + } + + public Router Merge(Router otherRouter) + { + var merged = CloneRoutes(); + foreach (var route in otherRouter.routes) + { + AddRoute(merged, route.Method, route.Path, route.HandlerFunction); + } + + return new Router(merged); + } + + internal IReadOnlyList GetRoutes() => routes; + + private Router AddRoute(MethodOrAny method, string path, string handlerFunction) + { + var merged = CloneRoutes(); + AddRoute(merged, method, path, handlerFunction); + return new Router(merged); + } + + private List CloneRoutes() => new(routes); + + private static void AddRoute( + List routes, + MethodOrAny method, + string path, + string handlerFunction + ) + { + AssertValidPath(path); + ArgumentException.ThrowIfNullOrEmpty(handlerFunction); + + var candidate = new RouteSpec(method, path, handlerFunction); + if (routes.Exists(route => RoutesOverlap(route, candidate))) + { + throw new ArgumentException($"Route conflict for `{path}`", nameof(path)); + } + + routes.Add(candidate); + } + + private static string JoinPaths(string prefix, string suffix) + { + if (prefix == "/") + { + return suffix; + } + if (suffix == "/") + { + return prefix; + } + + prefix = prefix.TrimEnd('/'); + suffix = suffix.TrimStart('/'); + return $"{prefix}/{suffix}"; + } + + private static bool RoutesOverlap(RouteSpec a, RouteSpec b) + { + if (!string.Equals(a.Path, b.Path, StringComparison.Ordinal)) + { + return false; + } + + return a.Method is MethodOrAny.Any + || b.Method is MethodOrAny.Any + || Equals(a.Method, b.Method); + } + + private static void AssertValidPath(string path) + { + ArgumentNullException.ThrowIfNull(path); + if (path.Length > 0 && path[0] != '/') + { + throw new ArgumentException($"Route paths must start with `/`: {path}", nameof(path)); + } + foreach (var c in path) + { + if (!CharacterIsAcceptableForRoutePath(c)) + { + throw new ArgumentException( + $"Route paths may contain only {AcceptableRoutePathCharsHumanDescription}: {path}", + nameof(path) + ); + } + } + } + + private static bool CharacterIsAcceptableForRoutePath(char c) => + (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c is '-' or '_' or '~' or '/'; +} diff --git a/crates/bindings-csharp/Runtime/bindings.c b/crates/bindings-csharp/Runtime/bindings.c index f2d2d1ca919..f0b2a2cc9f0 100644 --- a/crates/bindings-csharp/Runtime/bindings.c +++ b/crates/bindings-csharp/Runtime/bindings.c @@ -187,20 +187,24 @@ EXPORT(int16_t, __call_reducer__, &conn_id_0, &conn_id_1, ×tamp, &args, &error); -EXPORT(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, BytesSource args, BytesSink result_sink), +EXPORT(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, BytesSource args, BytesSink result_sink), &id, &sender_0, &sender_1, &sender_2, &sender_3, - &conn_id_0, &conn_id_1, - ×tamp, &args, &result_sink); - -EXPORT(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 rows), + &conn_id_0, &conn_id_1, + ×tamp, &args, &result_sink); + +EXPORT(int16_t, __call_http_handler__, + (uint32_t id, uint64_t timestamp, BytesSource request, BytesSink result_sink), + &id, ×tamp, &request, &result_sink); + +EXPORT(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 rows), &id, &sender_0, &sender_1, &sender_2, &sender_3, &args, &rows); From 8e4a4a50454609b6331bbb0f97e09a4b74622d0b Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Thu, 14 May 2026 10:13:57 -0700 Subject: [PATCH 2/5] Cleaned up http handlers and added smoketests --- .../BSATN.Runtime/HttpWireTypes.cs | 16 - crates/bindings-csharp/Codegen/Diag.cs | 18 + crates/bindings-csharp/Codegen/Module.cs | 133 +++-- .../Runtime.Tests/RouterTests.cs | 71 ++- crates/bindings-csharp/Runtime/Attrs.cs | 5 +- .../bindings-csharp/Runtime/HandlerContext.cs | 188 ++---- crates/bindings-csharp/Runtime/Http.cs | 20 +- .../Runtime/Internal/Module.cs | 29 +- .../Runtime/ProcedureContext.cs | 217 ++----- crates/bindings-csharp/Runtime/Router.cs | 47 +- .../Runtime/TransactionalContextState.cs | 199 +++++++ crates/bindings-csharp/Runtime/bindings.c | 5 +- .../tests/smoketests/http_routes.rs | 533 ++++++++++++++++-- .../00200-functions/00600-HTTP-handlers.md | 100 ++++ 14 files changed, 1084 insertions(+), 497 deletions(-) create mode 100644 crates/bindings-csharp/Runtime/TransactionalContextState.cs diff --git a/crates/bindings-csharp/BSATN.Runtime/HttpWireTypes.cs b/crates/bindings-csharp/BSATN.Runtime/HttpWireTypes.cs index 91aa4e22963..b0efb0c3d4a 100644 --- a/crates/bindings-csharp/BSATN.Runtime/HttpWireTypes.cs +++ b/crates/bindings-csharp/BSATN.Runtime/HttpWireTypes.cs @@ -75,19 +75,3 @@ public partial struct HttpResponseWire public HttpVersionWire Version; public ushort Code; } - -[Type] -[EditorBrowsable(EditorBrowsableState.Never)] -public partial struct HttpRequestAndBodyWire -{ - public HttpRequestWire Request; - public byte[] Body; -} - -[Type] -[EditorBrowsable(EditorBrowsableState.Never)] -public partial struct HttpResponseAndBodyWire -{ - public HttpResponseWire Response; - public byte[] Body; -} diff --git a/crates/bindings-csharp/Codegen/Diag.cs b/crates/bindings-csharp/Codegen/Diag.cs index 81375217b8f..f60b67e45c8 100644 --- a/crates/bindings-csharp/Codegen/Diag.cs +++ b/crates/bindings-csharp/Codegen/Diag.cs @@ -259,6 +259,15 @@ string typeName _ => Location.None ); + public static readonly ErrorDescriptor> DuplicateHttpRouters = + new( + group, + "Multiple [SpacetimeDB.HttpRouter] declarations", + fullNames => + $"[SpacetimeDB.HttpRouter] is declared multiple times: {string.Join(", ", fullNames)}", + _ => Location.None + ); + public static readonly ErrorDescriptor TableLevelIndexMissingAccessor = new( group, @@ -277,6 +286,15 @@ string typeName method => method.ParameterList ); + public static readonly ErrorDescriptor HttpHandlerSignature = + new( + group, + "HTTP handlers must be non-generic methods with exactly two parameters", + method => + $"HTTP handler method {method.Identifier} must be non-generic and take exactly two parameters.", + method => method.ParameterList + ); + public static readonly ErrorDescriptor HttpHandlerRequestParam = new( group, diff --git a/crates/bindings-csharp/Codegen/Module.cs b/crates/bindings-csharp/Codegen/Module.cs index 5f91a9e4971..d118adfd395 100644 --- a/crates/bindings-csharp/Codegen/Module.cs +++ b/crates/bindings-csharp/Codegen/Module.cs @@ -1753,7 +1753,6 @@ public Scope.Extensions GenerateSchedule() record HttpHandlerDeclaration { public readonly string Name; - public readonly string? CanonicalName; public readonly string FullName; private readonly bool HasWrongSignature; @@ -1763,11 +1762,46 @@ public HttpHandlerDeclaration(GeneratorAttributeSyntaxContext context, DiagRepor { var methodSyntax = (MethodDeclarationSyntax)context.TargetNode; var method = (IMethodSymbol)context.TargetSymbol; - var attr = context.Attributes.Single().ParseAs(); + var compilation = context.SemanticModel.Compilation; + + if (method.Arity != 0 || method.Parameters.Length != 2) + { + diag.Report(ErrorDescriptor.HttpHandlerSignature, methodSyntax); + HasWrongSignature = true; + } if ( method.Parameters.FirstOrDefault()?.Type - is not INamedTypeSymbol { Name: "HandlerContext" } + is not INamedTypeSymbol + { + Name: "HandlerContext", + Arity: 0, + ContainingType: null, + ContainingNamespace: + { + Name: "SpacetimeDB", + ContainingNamespace: { IsGlobalNamespace: true } + } + } + && methodSyntax.ParameterList.Parameters.FirstOrDefault()?.Type + is not IdentifierNameSyntax { Identifier.ValueText: "HandlerContext" } + && methodSyntax.ParameterList.Parameters.FirstOrDefault()?.Type + is not QualifiedNameSyntax + { + Left: IdentifierNameSyntax { Identifier.ValueText: "SpacetimeDB" }, + Right: IdentifierNameSyntax { Identifier.ValueText: "HandlerContext" } + } + && methodSyntax.ParameterList.Parameters.FirstOrDefault()?.Type + is not QualifiedNameSyntax + { + Left: + AliasQualifiedNameSyntax + { + Alias.Identifier.ValueText: "global", + Name: IdentifierNameSyntax { Identifier.ValueText: "SpacetimeDB" } + }, + Right: IdentifierNameSyntax { Identifier.ValueText: "HandlerContext" } + } ) { diag.Report(ErrorDescriptor.HttpHandlerContextParam, methodSyntax); @@ -1775,8 +1809,10 @@ public HttpHandlerDeclaration(GeneratorAttributeSyntaxContext context, DiagRepor } if ( - method.Parameters.ElementAtOrDefault(1)?.Type - is not INamedTypeSymbol { Name: "HttpRequest" } + method.Parameters.ElementAtOrDefault(1)?.Type is not { } requestType + || compilation.GetTypeByMetadataName("SpacetimeDB.HttpRequest") + is not { } expectedRequestType + || !SymbolEqualityComparer.Default.Equals(requestType, expectedRequestType) ) { diag.Report(ErrorDescriptor.HttpHandlerRequestParam, methodSyntax); @@ -1784,7 +1820,9 @@ public HttpHandlerDeclaration(GeneratorAttributeSyntaxContext context, DiagRepor } if ( - method.ReturnType is not INamedTypeSymbol { Name: "HttpResponse" } + compilation.GetTypeByMetadataName("SpacetimeDB.HttpResponse") + is not { } expectedResponseType + || !SymbolEqualityComparer.Default.Equals(method.ReturnType, expectedResponseType) ) { diag.Report(ErrorDescriptor.HttpHandlerReturnType, methodSyntax); @@ -1801,15 +1839,14 @@ public HttpHandlerDeclaration(GeneratorAttributeSyntaxContext context, DiagRepor } } - CanonicalName = attr.Name; FullName = SymbolToName(method); } public string GenerateClass() { - var invocation = HasWrongSignature - ? "throw new System.InvalidOperationException(\"Invalid HTTP handler signature.\")" - : $"{FullName}((SpacetimeDB.HandlerContext)ctx, request)"; + var body = HasWrongSignature + ? "throw new System.InvalidOperationException(\"Invalid HTTP handler signature.\");" + : $"return {FullName}((SpacetimeDB.HandlerContext)ctx, request);"; return $$""" class {{Identifier}} : SpacetimeDB.Internal.IHttpHandler { @@ -1821,7 +1858,7 @@ public SpacetimeDB.HttpResponse Invoke( SpacetimeDB.HandlerContextBase ctx, SpacetimeDB.HttpRequest request ) { - return {{invocation}}; + {{body}} } } """; @@ -1837,11 +1874,14 @@ public HttpRouterDeclaration(GeneratorAttributeSyntaxContext context, DiagReport { var methodSyntax = (MethodDeclarationSyntax)context.TargetNode; var method = (IMethodSymbol)context.TargetSymbol; + var compilation = context.SemanticModel.Compilation; if ( !method.IsStatic + || method.Arity != 0 || method.Parameters.Length != 0 - || method.ReturnType is not INamedTypeSymbol { Name: "Router" } + || compilation.GetTypeByMetadataName("SpacetimeDB.Router") is not { } expectedRouterType + || !SymbolEqualityComparer.Default.Equals(method.ReturnType, expectedRouterType) ) { diag.Report(ErrorDescriptor.HttpRouterSignature, methodSyntax); @@ -2199,27 +2239,23 @@ public void Initialize(IncrementalGeneratorInitializationContext context) "HttpHandler", context, httpHandlers - .Select((h, ct) => (h.Name, h.FullName, h.CanonicalName, Class: h.GenerateClass())) + .Select((h, ct) => (h.Name, h.FullName, Class: h.GenerateClass())) .WithTrackingName("SpacetimeDB.HttpHandler.GenerateClass"), h => h.Name, h => h.FullName ); - var httpRouters = CollectDistinct( - "HttpRouter", - context, - context - .SyntaxProvider.ForAttributeWithMetadataName( - fullyQualifiedMetadataName: typeof(HttpRouterAttribute).FullName, - predicate: (node, ct) => true, - transform: (context, ct) => - context.ParseWithDiags(diag => new HttpRouterDeclaration(context, diag)) - ) - .ReportDiagnostics(context) - .WithTrackingName("SpacetimeDB.HttpRouter.Parse"), - r => r.FullName, - r => r.FullName - ); + var httpRouters = context + .SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: typeof(HttpRouterAttribute).FullName, + predicate: (node, ct) => true, + transform: (context, ct) => + context.ParseWithDiags(diag => new HttpRouterDeclaration(context, diag)) + ) + .ReportDiagnostics(context) + .Collect() + .Select((routers, ct) => new EquatableArray(routers)) + .WithTrackingName("SpacetimeDB.HttpRouter.Collect"); var tableAccessors = CollectDistinct( "Table", @@ -2316,6 +2352,15 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ); } + if (httpRouters.Array.Length > 1) + { + context.ReportDiagnostic( + ErrorDescriptor.DuplicateHttpRouters.ToDiag( + httpRouters.Array.Select(r => r.FullName) + ) + ); + } + var settingsRegistration = settings.Array.Length == 1 && settings.Array[0].CaseConversionPolicy is { } policyName @@ -2346,13 +2391,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) $"SpacetimeDB.Internal.Module.RegisterExplicitFunctionName(\"{EscapeStringLiteral(p.Name)}\", \"{EscapeStringLiteral(p.CanonicalName!)}\");" ) ) - .Concat( - addHttpHandlers - .Array.Where(h => !string.IsNullOrEmpty(h.CanonicalName)) - .Select(h => - $"SpacetimeDB.Internal.Module.RegisterExplicitFunctionName(\"{EscapeStringLiteral(h.Name)}\", \"{EscapeStringLiteral(h.CanonicalName!)}\");" - ) - ) .Concat( views .Array.Where(v => !string.IsNullOrEmpty(v.CanonicalName)) @@ -2396,6 +2434,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) "\n", tableDecls.Array.SelectMany(t => t.GenerateQueryBuilderMembers()) ); + if (string.IsNullOrWhiteSpace(queryBuilderMembers)) + { + queryBuilderMembers = "public readonly partial struct QueryBuilder { }"; + } // Don't generate the FFI boilerplate if there are no tables or reducers. if ( tableAccessors.Array.IsEmpty @@ -2425,6 +2467,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) namespace SpacetimeDB { {{queryBuilderMembers}} + public static class Handlers { + {{string.Join("\n", addHttpHandlers.Select(r => + $"public static readonly global::SpacetimeDB.Handler {EscapeIdentifier(r.Name)} = new(nameof({r.FullName}));" + ))}} + } public sealed record ReducerContext : DbContext, Internal.IReducerContext { public readonly Identity Sender; public readonly ConnectionId? ConnectionId; @@ -2732,10 +2779,10 @@ public static void Main() { "\n", tableAccessors.Select(t => $"SpacetimeDB.Internal.Module.RegisterTable<{t.tableName}, SpacetimeDB.Internal.TableHandles.{EscapeIdentifier(t.tableAccessorName)}>();") )}} - {{string.Join( - "\n", - httpRouters.Where(r => r.IsValid) - .Select(r => $"SpacetimeDB.Internal.Module.RegisterHttpRouter({r.FullName}());") + {{( + httpRouters.Array.FirstOrDefault(r => r.IsValid) is { } router + ? $"SpacetimeDB.Internal.Module.RegisterHttpRouter({router.FullName}());" + : string.Empty )}} {{string.Join( "\n", @@ -2815,12 +2862,16 @@ public static SpacetimeDB.Internal.Errno __call_http_handler__( uint id, SpacetimeDB.Timestamp timestamp, SpacetimeDB.Internal.BytesSource request, - SpacetimeDB.Internal.BytesSink result_sink + SpacetimeDB.Internal.BytesSource request_body, + SpacetimeDB.Internal.BytesSink response_sink, + SpacetimeDB.Internal.BytesSink response_body_sink ) => SpacetimeDB.Internal.Module.__call_http_handler__( id, timestamp, request, - result_sink + request_body, + response_sink, + response_body_sink ); [UnmanagedCallersOnly(EntryPoint = "__call_view__")] diff --git a/crates/bindings-csharp/Runtime.Tests/RouterTests.cs b/crates/bindings-csharp/Runtime.Tests/RouterTests.cs index f745b02606c..341457ae41d 100644 --- a/crates/bindings-csharp/Runtime.Tests/RouterTests.cs +++ b/crates/bindings-csharp/Runtime.Tests/RouterTests.cs @@ -4,12 +4,18 @@ namespace Runtime.Tests; public class RouterTests { + private static class TestHandlers + { + public static readonly Handler GetHandler = new(nameof(RouterTests.GetHandler)); + public static readonly Handler PostHandler = new(nameof(RouterTests.PostHandler)); + } + [Fact] public void AllowsDistinctMethodsOnSamePath() { var router = Router.New() - .Get("/hooks", nameof(GetHandler)) - .Post("/hooks", nameof(PostHandler)); + .Get("/hooks", TestHandlers.GetHandler) + .Post("/hooks", TestHandlers.PostHandler); Assert.NotNull(router); } @@ -18,7 +24,9 @@ public void AllowsDistinctMethodsOnSamePath() public void RejectsAnyConflictOnSamePath() { var ex = Assert.Throws( - () => Router.New().Any("/hooks", nameof(GetHandler)).Get("/hooks", nameof(PostHandler)) + () => Router.New() + .Any("/hooks", TestHandlers.GetHandler) + .Get("/hooks", TestHandlers.PostHandler) ); Assert.Contains("Route conflict", ex.Message); @@ -28,7 +36,7 @@ public void RejectsAnyConflictOnSamePath() public void RejectsInvalidPathCharacters() { var ex = Assert.Throws( - () => Router.New().Get("/Bad", nameof(GetHandler)) + () => Router.New().Get("/Bad", TestHandlers.GetHandler) ); Assert.Contains("Route paths may contain only", ex.Message); @@ -37,43 +45,66 @@ public void RejectsInvalidPathCharacters() [Fact] public void NestJoinsPathsWithoutDoubleSlash() { - var router = Router.New().Nest("/api", Router.New().Get("/hooks", nameof(GetHandler))); + var router = Router.New().Nest( + "/api", + Router.New().Get("/hooks", TestHandlers.GetHandler) + ); Assert.NotNull(router); } [Fact] - public void NestAllowsExistingSiblingPrefix() + public void NestRejectsExistingSiblingPrefix() { - var router = Router.New() - .Get("/apiv2", nameof(GetHandler)) - .Nest("/api", Router.New().Get("/hooks", nameof(PostHandler))); + var ex = Assert.Throws( + () => Router.New().Get("/apiv2", TestHandlers.GetHandler) + .Nest( + "/api", + Router.New().Get("/hooks", TestHandlers.PostHandler) + ) + ); - Assert.NotNull(router); + Assert.Contains("Cannot nest router", ex.Message); } [Fact] - public void NestAllowsExistingRouteAtNestedPrefix() + public void NestRejectsExistingRouteAtNestedPrefix() { - var router = Router.New() - .Get("/api", nameof(GetHandler)) - .Nest("/api", Router.New().Get("/hooks", nameof(PostHandler))); + var ex = Assert.Throws( + () => Router.New().Get("/api", TestHandlers.GetHandler) + .Nest( + "/api", + Router.New().Get("/hooks", TestHandlers.PostHandler) + ) + ); - Assert.NotNull(router); + Assert.Contains("Cannot nest router", ex.Message); } [Fact] public void NestStillRejectsExactRouteConflicts() { var ex = Assert.Throws( - () => Router.New().Get("/api/hooks", nameof(GetHandler)) - .Nest("/api", Router.New().Get("/hooks", nameof(PostHandler))) + () => Router.New().Get("/api/hooks", TestHandlers.GetHandler) + .Nest( + "/api", + Router.New().Get("/hooks", TestHandlers.PostHandler) + ) ); - Assert.Contains("Route conflict", ex.Message); + Assert.Contains("Cannot nest router", ex.Message); + } + + private sealed class TestHandlerContext() + : HandlerContextBase(new System.Random(), default) + { + protected override HandlerTxContextBase CreateTxContext(SpacetimeDB.Internal.TxContext inner) => + throw new NotSupportedException(); + + protected internal override LocalBase CreateLocal() => throw new NotSupportedException(); } - private static void GetHandler() { } + private static HttpResponse GetHandler(TestHandlerContext _, HttpRequest __) => default; - private static void PostHandler() { } + private static HttpResponse PostHandler(TestHandlerContext _, HttpRequest __) => default; } diff --git a/crates/bindings-csharp/Runtime/Attrs.cs b/crates/bindings-csharp/Runtime/Attrs.cs index 0e71630ee72..46daec5eec9 100644 --- a/crates/bindings-csharp/Runtime/Attrs.cs +++ b/crates/bindings-csharp/Runtime/Attrs.cs @@ -205,10 +205,7 @@ public sealed class ProcedureAttribute() : Attribute } [AttributeUsage(AttributeTargets.Method, Inherited = false)] - public sealed class HttpHandlerAttribute() : Attribute - { - public string? Name { get; init; } - } + public sealed class HttpHandlerAttribute() : Attribute { } [AttributeUsage(AttributeTargets.Method, Inherited = false)] public sealed class HttpRouterAttribute() : Attribute { } diff --git a/crates/bindings-csharp/Runtime/HandlerContext.cs b/crates/bindings-csharp/Runtime/HandlerContext.cs index 40eafaab79f..e01f8f60b69 100644 --- a/crates/bindings-csharp/Runtime/HandlerContext.cs +++ b/crates/bindings-csharp/Runtime/HandlerContext.cs @@ -4,10 +4,10 @@ namespace SpacetimeDB; using System.Diagnostics.CodeAnalysis; #pragma warning disable STDB_UNSTABLE -public abstract class HandlerContextBase(Random random, Timestamp time) +public abstract class HandlerContextBase { - public Random Rng { get; } = random; - public Timestamp Timestamp { get; private set; } = time; + public Random Rng => txState.Rng; + public Timestamp Timestamp => txState.Timestamp; // NOTE: The host rejects procedure HTTP requests while a mut transaction is open // (WOULD_BLOCK_TRANSACTION). Avoid calling `Http.*` inside WithTx. @@ -15,41 +15,32 @@ public abstract class HandlerContextBase(Random random, Timestamp time) // **Note:** must be 0..=u32::MAX protected int CounterUuid = 0; + private readonly TransactionalContextState txState; - private Internal.TxContext? txContext; - private HandlerTxContextBase? cachedUserTxContext; + protected HandlerContextBase(Random random, Timestamp time) + { + txState = new( + random, + time, + timestamp => + new Internal.TxContext( + CreateLocal(), + default, + null, + timestamp, + AuthCtx.BuildFromSystemTables(null, default), + random + ), + inner => CreateTxContext(inner) + ); + } protected abstract HandlerTxContextBase CreateTxContext(Internal.TxContext inner); protected internal abstract LocalBase CreateLocal(); - private protected HandlerTxContextBase RequireTxContext() - { - var inner = - txContext - ?? throw new InvalidOperationException("Transaction context was not initialised."); - cachedUserTxContext ??= CreateTxContext(inner); - cachedUserTxContext.Refresh(inner); - return cachedUserTxContext; - } + public Internal.TxContext EnterTxContext(long timestampMicros) => txState.EnterTxContext(timestampMicros); - public Internal.TxContext EnterTxContext(long timestampMicros) - { - var timestamp = new Timestamp(timestampMicros); - Timestamp = timestamp; - txContext = - txContext?.WithTimestamp(timestamp) - ?? new Internal.TxContext( - CreateLocal(), - default, - null, - timestamp, - AuthCtx.BuildFromSystemTables(null, default), - Rng - ); - return txContext; - } - - public void ExitTxContext() => txContext = null; + public void ExitTxContext() => txState.ExitTxContext(); public readonly struct TxOutcome(bool isSuccess, TResult? value, Exception? error) { @@ -72,7 +63,7 @@ public TResult UnwrapOrThrow() => [Experimental("STDB_UNSTABLE")] public TResult WithTx(Func body) => - TryWithTx(tx => Result.Ok(body(tx))).UnwrapOrThrow(); + txState.WithTx(body); [Experimental("STDB_UNSTABLE")] public TxOutcome TryWithTx( @@ -80,135 +71,22 @@ Func> body ) where TError : Exception { - try - { - var result = RunWithRetry(body); - - return result switch - { - Result.OkR(var value) => TxOutcome.Success(value), - Result.ErrR(var error) => TxOutcome.Failure(error), - _ => throw new InvalidOperationException("Unknown Result variant."), - }; - } - catch (Exception ex) - { - return TxOutcome.Failure(ex); - } - } - - private long StartMutTx() - { - var status = Internal.FFI.procedure_start_mut_tx(out var micros); - Internal.FFI.ErrnoHelpers.ThrowIfError(status); - return micros; - } - - private void CommitMutTx() - { - var status = Internal.FFI.procedure_commit_mut_tx(); - Internal.FFI.ErrnoHelpers.ThrowIfError(status); - } - - private void AbortMutTx() - { - var status = Internal.FFI.procedure_abort_mut_tx(); - Internal.FFI.ErrnoHelpers.ThrowIfError(status); - } - - private bool CommitMutTxWithRetry(Func retryBody) - { - try - { - CommitMutTx(); - return true; - } - catch (TransactionNotAnonymousException) - { - return false; - } - catch (StdbException) - { - Log.Warn("Committing anonymous transaction failed; retrying once."); - if (retryBody()) - { - CommitMutTx(); - return true; - } - return false; - } - } - - private Result RunWithRetry( - Func> body - ) - where TError : Exception - { - var result = RunOnce(body); - if (result is Result.ErrR) - { - return result; - } - - bool Retry() - { - result = RunOnce(body); - return result is Result.OkR; - } - - if (!CommitMutTxWithRetry(Retry)) - { - return result; - } - - return result; - } - - private Result RunOnce( - Func> body - ) - where TError : Exception - { - var micros = StartMutTx(); - using var guard = new AbortGuard(AbortMutTx); - EnterTxContext(micros); - var txCtx = RequireTxContext(); - - Result result = body(txCtx); - - if (result is Result.OkR) - { - guard.Disarm(); - return result; - } - - AbortMutTx(); - guard.Disarm(); - return result; - } - - private sealed class AbortGuard(Action abort) : IDisposable - { - private readonly Action abort = abort; - private bool disarmed; - - public void Disarm() => disarmed = true; - - public void Dispose() - { - if (!disarmed) - { - abort(); - } - } + var outcome = txState.TryWithTx(body); + return outcome.IsSuccess + ? TxOutcome.Success(outcome.Value!) + : TxOutcome.Failure( + outcome.Error + ?? new InvalidOperationException("Transaction failed without an error object.") + ); } } -public abstract class HandlerTxContextBase(Internal.TxContext inner) +public abstract class HandlerTxContextBase(Internal.TxContext inner) : IRefreshableTxContext { internal Internal.TxContext Inner { get; private set; } = inner; internal void Refresh(Internal.TxContext inner) => Inner = inner; + void IRefreshableTxContext.Refresh(Internal.TxContext inner) => Refresh(inner); public LocalBase Db => (LocalBase)Inner.Db; public Timestamp Timestamp => Inner.Timestamp; diff --git a/crates/bindings-csharp/Runtime/Http.cs b/crates/bindings-csharp/Runtime/Http.cs index 44fb40831fd..3debdd52966 100644 --- a/crates/bindings-csharp/Runtime/Http.cs +++ b/crates/bindings-csharp/Runtime/Http.cs @@ -418,23 +418,19 @@ private static HttpVersionWire ToWireVersion(HttpVersion version) => private static HttpHeaderPairWire ToWireHeader(HttpHeader header) => new() { Name = header.Name, Value = header.Value }; - internal static HttpRequest FromWire(HttpRequestAndBodyWire requestAndBodyWire) - { - var requestWire = requestAndBodyWire.Request; - return new HttpRequest + internal static HttpRequest FromWire(HttpRequestWire requestWire, byte[] body) => + new() { Uri = requestWire.Uri, Method = FromWireMethod(requestWire.Method), Headers = requestWire.Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)).ToList(), - Body = new HttpBody(requestAndBodyWire.Body), + Body = new HttpBody(body), Version = FromWireVersion(requestWire.Version), }; - } - internal static HttpResponseAndBodyWire ToWire(HttpResponse response) => - new() - { - Response = new HttpResponseWire + internal static (HttpResponseWire Response, byte[] Body) ToWire(HttpResponse response) => + ( + new HttpResponseWire { Headers = new HttpHeadersWire { @@ -443,8 +439,8 @@ internal static HttpResponseAndBodyWire ToWire(HttpResponse response) => Version = ToWireVersion(response.Version), Code = response.StatusCode, }, - Body = response.Body.ToBytes(), - }; + response.Body.ToBytes() + ); private static HttpMethod FromWireMethod(HttpMethodWire methodWire) => methodWire switch diff --git a/crates/bindings-csharp/Runtime/Internal/Module.cs b/crates/bindings-csharp/Runtime/Internal/Module.cs index 0518de26841..edeada80d44 100644 --- a/crates/bindings-csharp/Runtime/Internal/Module.cs +++ b/crates/bindings-csharp/Runtime/Internal/Module.cs @@ -324,6 +324,14 @@ public static void RegisterHttpRouter(SpacetimeDB.Router router) { foreach (var route in router.GetRoutes()) { + if (!httpHandlers.Any(handler => handler.MakeHandlerDef().SourceName == route.HandlerFunction)) + { + throw new ArgumentException( + $"HTTP router references unknown handler `{route.HandlerFunction}`", + nameof(router) + ); + } + moduleDef.RegisterHttpRoute( new RawHttpRouteDefV10( HandlerFunction: route.HandlerFunction, @@ -565,7 +573,14 @@ BytesSink resultSink } } - public static Errno __call_http_handler__(uint id, Timestamp timestamp, BytesSource request, BytesSink resultSink) + public static Errno __call_http_handler__( + uint id, + Timestamp timestamp, + BytesSource request, + BytesSource requestBody, + BytesSink responseSink, + BytesSink responseBodySink + ) { try { @@ -576,17 +591,19 @@ public static Errno __call_http_handler__(uint id, Timestamp timestamp, BytesSou var requestBytes = request.Consume(); using var stream = new MemoryStream(requestBytes); using var reader = new BinaryReader(stream); - var requestAndBody = new HttpRequestAndBodyWire.BSATN().Read(reader); + var requestWire = new HttpRequestWire.BSATN().Read(reader); if (stream.Position != stream.Length) { throw new Exception("Unrecognised extra bytes in the HTTP handler request"); } - var response = httpHandlers[(int)id].Invoke(ctx, SpacetimeDB.HttpClient.FromWire(requestAndBody)); - var responseWire = SpacetimeDB.HttpClient.ToWire(response); - resultSink.Write( - IStructuralReadWrite.ToBytes(new HttpResponseAndBodyWire.BSATN(), responseWire) + var response = httpHandlers[(int)id].Invoke( + ctx, + SpacetimeDB.HttpClient.FromWire(requestWire, requestBody.Consume()) ); + var (responseWire, responseBody) = SpacetimeDB.HttpClient.ToWire(response); + responseSink.Write(IStructuralReadWrite.ToBytes(new HttpResponseWire.BSATN(), responseWire)); + responseBodySink.Write(responseBody); return Errno.OK; } diff --git a/crates/bindings-csharp/Runtime/ProcedureContext.cs b/crates/bindings-csharp/Runtime/ProcedureContext.cs index 4eb8583b7d5..f8d81e79f1e 100644 --- a/crates/bindings-csharp/Runtime/ProcedureContext.cs +++ b/crates/bindings-csharp/Runtime/ProcedureContext.cs @@ -3,19 +3,14 @@ namespace SpacetimeDB; using System.Diagnostics.CodeAnalysis; #pragma warning disable STDB_UNSTABLE -public abstract class ProcedureContextBase( - Identity sender, - ConnectionId? connectionId, - Random random, - Timestamp time -) : Internal.IInternalProcedureContext +public abstract class ProcedureContextBase : Internal.IInternalProcedureContext { public static Identity Identity => Internal.IProcedureContext.GetIdentity(); - public Identity Sender { get; } = sender; - public ConnectionId? ConnectionId { get; } = connectionId; - public Random Rng { get; } = random; - public Timestamp Timestamp { get; private set; } = time; - public AuthCtx SenderAuth { get; } = AuthCtx.BuildFromSystemTables(connectionId, sender); + public Identity Sender { get; } + public ConnectionId? ConnectionId { get; } + public Random Rng => txState.Rng; + public Timestamp Timestamp => txState.Timestamp; + public AuthCtx SenderAuth { get; } // NOTE: The host rejects procedure HTTP requests while a mut transaction is open // (WOULD_BLOCK_TRANSACTION). Avoid calling `Http.*` inside WithTx. @@ -23,40 +18,40 @@ Timestamp time // **Note:** must be 0..=u32::MAX protected int CounterUuid = 0; - private Internal.TxContext? txContext; - private ProcedureTxContextBase? cachedUserTxContext; + private readonly TransactionalContextState txState; - protected abstract ProcedureTxContextBase CreateTxContext(Internal.TxContext inner); - protected internal abstract LocalBase CreateLocal(); - - private protected ProcedureTxContextBase RequireTxContext() + protected ProcedureContextBase( + Identity sender, + ConnectionId? connectionId, + Random random, + Timestamp time + ) { - var inner = - txContext - ?? throw new InvalidOperationException("Transaction context was not initialised."); - cachedUserTxContext ??= CreateTxContext(inner); - cachedUserTxContext.Refresh(inner); - return cachedUserTxContext; + Sender = sender; + ConnectionId = connectionId; + SenderAuth = AuthCtx.BuildFromSystemTables(connectionId, sender); + txState = new( + random, + time, + timestamp => + new Internal.TxContext( + CreateLocal(), + Sender, + ConnectionId, + timestamp, + SenderAuth, + random + ), + inner => CreateTxContext(inner) + ); } - public Internal.TxContext EnterTxContext(long timestampMicros) - { - var timestamp = new Timestamp(timestampMicros); - Timestamp = timestamp; - txContext = - txContext?.WithTimestamp(timestamp) - ?? new Internal.TxContext( - CreateLocal(), - Sender, - ConnectionId, - timestamp, - SenderAuth, - Rng - ); - return txContext; - } + protected abstract ProcedureTxContextBase CreateTxContext(Internal.TxContext inner); + protected internal abstract LocalBase CreateLocal(); + + public Internal.TxContext EnterTxContext(long timestampMicros) => txState.EnterTxContext(timestampMicros); - public void ExitTxContext() => txContext = null; + public void ExitTxContext() => txState.ExitTxContext(); public readonly struct TxOutcome(bool isSuccess, TResult? value, Exception? error) { @@ -82,7 +77,7 @@ public TResult UnwrapOrThrow(Func fallbackFactory) => [Experimental("STDB_UNSTABLE")] public TResult WithTx(Func body) => - TryWithTx(tx => Result.Ok(body(tx))).UnwrapOrThrow(); + txState.WithTx(body); [Experimental("STDB_UNSTABLE")] public TxOutcome TryWithTx( @@ -90,144 +85,22 @@ Func> body ) where TError : Exception { - try - { - var result = RunWithRetry(body); - - return result switch - { - Result.OkR(var value) => TxOutcome.Success(value), - Result.ErrR(var error) => TxOutcome.Failure(error), - _ => throw new InvalidOperationException("Unknown Result variant."), - }; - } - catch (Exception ex) - { - return TxOutcome.Failure(ex); - } - } - - // Private transaction management methods (Rust-like encapsulation) - private long StartMutTx() - { - var status = Internal.FFI.procedure_start_mut_tx(out var micros); - Internal.FFI.ErrnoHelpers.ThrowIfError(status); - return micros; - } - - private void CommitMutTx() - { - var status = Internal.FFI.procedure_commit_mut_tx(); - Internal.FFI.ErrnoHelpers.ThrowIfError(status); - } - - private void AbortMutTx() - { - var status = Internal.FFI.procedure_abort_mut_tx(); - Internal.FFI.ErrnoHelpers.ThrowIfError(status); - } - - private bool CommitMutTxWithRetry(Func retryBody) - { - try - { - CommitMutTx(); - return true; - } - catch (TransactionNotAnonymousException) - { - return false; - } - catch (StdbException) - { - Log.Warn("Committing anonymous transaction failed; retrying once."); - if (retryBody()) - { - CommitMutTx(); - return true; - } - return false; - } - } - - private Result RunWithRetry( - Func> body - ) - where TError : Exception - { - var result = RunOnce(body); - if (result is Result.ErrR) - { - return result; - } - - bool Retry() - { - result = RunOnce(body); - return result is Result.OkR; - } - - if (!CommitMutTxWithRetry(Retry)) - { - return result; - } - - return result; - } - - private Result RunOnce( - Func> body - ) - where TError : Exception - { - var micros = StartMutTx(); - using var guard = new AbortGuard(AbortMutTx); - EnterTxContext(micros); - var txCtx = RequireTxContext(); - - Result result; - try - { - result = body(txCtx); - } - catch (Exception) - { - throw; - } - - if (result is Result.OkR) - { - guard.Disarm(); - return result; - } - - AbortMutTx(); - guard.Disarm(); - return result; - } - - private sealed class AbortGuard(Action abort) : IDisposable - { - private readonly Action abort = abort; - private bool disarmed; - - public void Disarm() => disarmed = true; - - public void Dispose() - { - if (!disarmed) - { - abort(); - } - } + var outcome = txState.TryWithTx(body); + return outcome.IsSuccess + ? TxOutcome.Success(outcome.Value!) + : TxOutcome.Failure( + outcome.Error + ?? new InvalidOperationException("Transaction failed without an error object.") + ); } } -public abstract class ProcedureTxContextBase(Internal.TxContext inner) +public abstract class ProcedureTxContextBase(Internal.TxContext inner) : IRefreshableTxContext { internal Internal.TxContext Inner { get; private set; } = inner; internal void Refresh(Internal.TxContext inner) => Inner = inner; + void IRefreshableTxContext.Refresh(Internal.TxContext inner) => Refresh(inner); public LocalBase Db => (LocalBase)Inner.Db; public Identity Sender => Inner.Sender; diff --git a/crates/bindings-csharp/Runtime/Router.cs b/crates/bindings-csharp/Runtime/Router.cs index 9907f205e3b..2c71ea366db 100644 --- a/crates/bindings-csharp/Runtime/Router.cs +++ b/crates/bindings-csharp/Runtime/Router.cs @@ -4,6 +4,9 @@ namespace SpacetimeDB; using System.Collections.Generic; using Internal; +public readonly record struct Handler(string FunctionName) +{ } + public sealed class Router { internal readonly record struct RouteSpec(MethodOrAny Method, string Path, string HandlerFunction); @@ -20,33 +23,40 @@ private Router(List routes) public static Router New() => new([]); - public Router Get(string path, string handlerFunction) => - AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Get(default)), path, handlerFunction); + public Router Get(string path, Handler handler) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Get(default)), path, handler); - public Router Head(string path, string handlerFunction) => - AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Head(default)), path, handlerFunction); + public Router Head(string path, Handler handler) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Head(default)), path, handler); - public Router Options(string path, string handlerFunction) => - AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Options(default)), path, handlerFunction); + public Router Options(string path, Handler handler) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Options(default)), path, handler); - public Router Put(string path, string handlerFunction) => - AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Put(default)), path, handlerFunction); + public Router Put(string path, Handler handler) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Put(default)), path, handler); - public Router Delete(string path, string handlerFunction) => - AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Delete(default)), path, handlerFunction); + public Router Delete(string path, Handler handler) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Delete(default)), path, handler); - public Router Post(string path, string handlerFunction) => - AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Post(default)), path, handlerFunction); + public Router Post(string path, Handler handler) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Post(default)), path, handler); - public Router Patch(string path, string handlerFunction) => - AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Patch(default)), path, handlerFunction); + public Router Patch(string path, Handler handler) => + AddRoute(new MethodOrAny.Method(new Internal.HttpMethod.Patch(default)), path, handler); - public Router Any(string path, string handlerFunction) => - AddRoute(new MethodOrAny.Any(default), path, handlerFunction); + public Router Any(string path, Handler handler) => + AddRoute(new MethodOrAny.Any(default), path, handler); public Router Nest(string path, Router subRouter) { AssertValidPath(path); + if (routes.Exists(route => route.Path.StartsWith(path, StringComparison.Ordinal))) + { + throw new ArgumentException( + $"Cannot nest router at `{path}`; existing routes overlap with nested path", + nameof(path) + ); + } var merged = CloneRoutes(); foreach (var route in subRouter.routes) @@ -71,10 +81,10 @@ public Router Merge(Router otherRouter) internal IReadOnlyList GetRoutes() => routes; - private Router AddRoute(MethodOrAny method, string path, string handlerFunction) + private Router AddRoute(MethodOrAny method, string path, Handler handler) { var merged = CloneRoutes(); - AddRoute(merged, method, path, handlerFunction); + AddRoute(merged, method, path, handler.FunctionName); return new Router(merged); } @@ -98,7 +108,6 @@ string handlerFunction routes.Add(candidate); } - private static string JoinPaths(string prefix, string suffix) { if (prefix == "/") diff --git a/crates/bindings-csharp/Runtime/TransactionalContextState.cs b/crates/bindings-csharp/Runtime/TransactionalContextState.cs new file mode 100644 index 00000000000..ef0d72de410 --- /dev/null +++ b/crates/bindings-csharp/Runtime/TransactionalContextState.cs @@ -0,0 +1,199 @@ +namespace SpacetimeDB; + +using System; +using SpacetimeDB.Internal; + +#pragma warning disable STDB_UNSTABLE +internal interface IRefreshableTxContext +{ + void Refresh(Internal.TxContext inner); +} + +internal readonly struct TxOutcomeCore(bool isSuccess, TResult? value, Exception? error) +{ + public bool IsSuccess { get; } = isSuccess; + public TResult? Value { get; } = value; + public Exception? Error { get; } = error; + + public static TxOutcomeCore Success(TResult value) => new(true, value, null); + + public static TxOutcomeCore Failure(Exception error) => new(false, default, error); +} + +internal sealed class TransactionalContextState( + Random random, + Timestamp time, + Func createInitialTxContext, + Func createTxContext +) + where TTxContext : class, IRefreshableTxContext +{ + public Random Rng { get; } = random; + public Timestamp Timestamp { get; private set; } = time; + + private Internal.TxContext? txContext; + private TTxContext? cachedUserTxContext; + + public Internal.TxContext EnterTxContext(long timestampMicros) + { + var timestamp = new Timestamp(timestampMicros); + Timestamp = timestamp; + txContext = txContext?.WithTimestamp(timestamp) ?? createInitialTxContext(timestamp); + return txContext; + } + + public void ExitTxContext() => txContext = null; + + public TTxContext RequireTxContext() + { + var inner = + txContext + ?? throw new InvalidOperationException("Transaction context was not initialised."); + cachedUserTxContext ??= createTxContext(inner); + cachedUserTxContext.Refresh(inner); + return cachedUserTxContext; + } + + public TResult WithTx(Func body) => + TryWithTx(tx => Result.Ok(body(tx))).UnwrapOrThrow(); + + public TxOutcomeCore TryWithTx( + Func> body + ) + where TError : Exception + { + try + { + var result = RunWithRetry(body); + + return result switch + { + Result.OkR(var value) => TxOutcomeCore.Success(value), + Result.ErrR(var error) => TxOutcomeCore.Failure(error), + _ => throw new InvalidOperationException("Unknown Result variant."), + }; + } + catch (Exception ex) + { + return TxOutcomeCore.Failure(ex); + } + } + + private long StartMutTx() + { + var status = FFI.procedure_start_mut_tx(out var micros); + FFI.ErrnoHelpers.ThrowIfError(status); + return micros; + } + + private void CommitMutTx() + { + var status = FFI.procedure_commit_mut_tx(); + FFI.ErrnoHelpers.ThrowIfError(status); + } + + private void AbortMutTx() + { + var status = FFI.procedure_abort_mut_tx(); + FFI.ErrnoHelpers.ThrowIfError(status); + } + + private bool CommitMutTxWithRetry(Func retryBody) + { + try + { + CommitMutTx(); + return true; + } + catch (TransactionNotAnonymousException) + { + return false; + } + catch (StdbException) + { + Log.Warn("Committing anonymous transaction failed; retrying once."); + if (retryBody()) + { + CommitMutTx(); + return true; + } + return false; + } + } + + private Result RunWithRetry( + Func> body + ) + where TError : Exception + { + var result = RunOnce(body); + if (result is Result.ErrR) + { + return result; + } + + bool Retry() + { + result = RunOnce(body); + return result is Result.OkR; + } + + if (!CommitMutTxWithRetry(Retry)) + { + return result; + } + + return result; + } + + private Result RunOnce( + Func> body + ) + where TError : Exception + { + var micros = StartMutTx(); + using var guard = new AbortGuard(AbortMutTx); + EnterTxContext(micros); + var txCtx = RequireTxContext(); + + var result = body(txCtx); + + if (result is Result.OkR) + { + guard.Disarm(); + return result; + } + + AbortMutTx(); + guard.Disarm(); + return result; + } + + private sealed class AbortGuard(Action abort) : IDisposable + { + private readonly Action abort = abort; + private bool disarmed; + + public void Disarm() => disarmed = true; + + public void Dispose() + { + if (!disarmed) + { + abort(); + } + } + } +} + +internal static class TxOutcomeCoreExtensions +{ + public static TResult UnwrapOrThrow(this TxOutcomeCore outcome) => + outcome.IsSuccess + ? outcome.Value! + : throw ( + outcome.Error + ?? new InvalidOperationException("Transaction failed without an error object.") + ); +} +#pragma warning restore STDB_UNSTABLE diff --git a/crates/bindings-csharp/Runtime/bindings.c b/crates/bindings-csharp/Runtime/bindings.c index f0b2a2cc9f0..57ed816d939 100644 --- a/crates/bindings-csharp/Runtime/bindings.c +++ b/crates/bindings-csharp/Runtime/bindings.c @@ -198,8 +198,9 @@ EXPORT(int16_t, __call_procedure__, ×tamp, &args, &result_sink); EXPORT(int16_t, __call_http_handler__, - (uint32_t id, uint64_t timestamp, BytesSource request, BytesSink result_sink), - &id, ×tamp, &request, &result_sink); + (uint32_t id, uint64_t timestamp, BytesSource request, BytesSource request_body, + BytesSink response_sink, BytesSink response_body_sink), + &id, ×tamp, &request, &request_body, &response_sink, &response_body_sink); EXPORT(int16_t, __call_view__, (uint32_t id, diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 40abde11f8e..6495d7b24df 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_dotnet, workspace_root, Smoketest}; use std::{fs, path::Path}; const MODULE_CODE: &str = r#" @@ -230,13 +230,350 @@ fn router() -> Router { } "#; +const CS_MODULE_CODE: &str = r#" +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE +public static partial class Module +{ + [SpacetimeDB.Table(Accessor = "Entry", Name = "entry", Public = true)] + public partial struct Entry + { + [SpacetimeDB.PrimaryKey] + public ulong Id; + + public string Value; + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse GetSimple(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + _ = request; + return TextResponse(200, "ok"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse PostInsert(HandlerContext ctx, HttpRequest request) + { + _ = request; + ctx.WithTx((HandlerTxContext tx) => + { + var id = tx.Db.Entry.Count; + tx.Db.Entry.Insert(new Entry { Id = id, Value = "posted" }); + return 0; + }); + return TextResponse(200, "inserted"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse GetCount(HandlerContext ctx, HttpRequest request) + { + _ = request; + var count = ctx.WithTx((HandlerTxContext tx) => tx.Db.Entry.Count); + return TextResponse(200, count.ToString()); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse AnyHandler(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + _ = request; + return TextResponse(200, "any"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse HeaderEcho(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + return TextResponse(200, HeaderValueUtf8(request, "x-echo")); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse SetResponseHeader(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + _ = request; + return new HttpResponse( + 200, + HttpVersion.Http11, + new List { new("x-response", "set") }, + HttpBody.FromString("header-set") + ); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse BodyHandler(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + _ = request; + return TextResponse(200, "non-empty"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse Teapot(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + _ = request; + return TextResponse(418, "teapot"); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Get("/get", Handlers.GetSimple) + .Post("/post", Handlers.PostInsert) + .Get("/count", Handlers.GetCount) + .Any("/any", Handlers.AnyHandler) + .Get("/header", Handlers.HeaderEcho) + .Get("/set-header", Handlers.SetResponseHeader) + .Get("/body", Handlers.BodyHandler) + .Get("/teapot", Handlers.Teapot); + + private static string HeaderValueUtf8(HttpRequest request, string headerName) + { + foreach (var header in request.Headers) + { + if (string.Equals(header.Name, headerName, StringComparison.OrdinalIgnoreCase)) + { + return Encoding.UTF8.GetString(header.Value); + } + } + return string.Empty; + } + + private static HttpResponse TextResponse(ushort statusCode, string body) => + new( + statusCode, + HttpVersion.Http11, + new List(), + HttpBody.FromString(body) + ); +} +"#; + +const CS_EXAMPLE_MODULE_CODE: &str = r#" +using System.Collections.Generic; +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE +public static partial class Module +{ + [SpacetimeDB.Table(Accessor = "Data", Name = "data", Public = true)] + public partial struct Data + { + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + public ulong Id; + + public byte[] Body; + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse Insert(HandlerContext ctx, HttpRequest request) + { + var body = request.Body.ToBytes(); + var id = ctx.WithTx((HandlerTxContext tx) => tx.Db.Data.Insert(new Data { Id = 0, Body = body }).Id); + return TextResponse(200, id.ToString()); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse Retrieve(HandlerContext ctx, HttpRequest request) + { + var idText = request.Uri.Split("id=", 2)[1]; + var id = ulong.Parse(idText); + var body = ctx.WithTx((HandlerTxContext tx) => tx.Db.Data.Id.Find(id)?.Body); + + if (body is not null) + { + return BytesResponse(200, body); + } + + return new HttpResponse(404, HttpVersion.Http11, new List(), HttpBody.Empty); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Post("/insert", Handlers.Insert) + .Get("/retrieve", Handlers.Retrieve); + + private static HttpResponse BytesResponse(ushort statusCode, byte[] body) => + new(statusCode, HttpVersion.Http11, new List(), new HttpBody(body)); + + private static HttpResponse TextResponse(ushort statusCode, string body) => + new(statusCode, HttpVersion.Http11, new List(), HttpBody.FromString(body)); +} +"#; + +const CS_STRICT_ROOT_ROUTING_MODULE_CODE: &str = r#" +using System.Collections.Generic; +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.HttpHandler] + public static HttpResponse EmptyRoot(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + _ = request; + return TextResponse("empty"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse SlashRoot(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + _ = request; + return TextResponse("slash"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse Foo(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + _ = request; + return TextResponse("foo"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse FooSlash(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + _ = request; + return TextResponse("foo-slash"); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Get("", Handlers.EmptyRoot) + .Get("/", Handlers.SlashRoot) + .Get("/foo", Handlers.Foo) + .Get("/foo/", Handlers.FooSlash); + + private static HttpResponse TextResponse(string body) => + new(200, HttpVersion.Http11, new List(), HttpBody.FromString(body)); +} +"#; + +const CS_STRICT_NON_ROOT_ROUTING_MODULE_CODE: &str = r#" +using System.Collections.Generic; +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.HttpHandler] + public static HttpResponse Foo(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + _ = request; + return TextResponse("foo"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse FooSlash(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + _ = request; + return TextResponse("foo-slash"); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Get("/foo", Handlers.Foo) + .Get("/foo/", Handlers.FooSlash); + + private static HttpResponse TextResponse(string body) => + new(200, HttpVersion.Http11, new List(), HttpBody.FromString(body)); +} +"#; + +const CS_FULL_URI_MODULE_CODE: &str = r#" +using System.Collections.Generic; +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.HttpHandler] + public static HttpResponse EchoUri(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + return new HttpResponse( + 200, + HttpVersion.Http11, + new List(), + HttpBody.FromString(request.Uri) + ); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New().Get("/echo-uri", Handlers.EchoUri); +} +"#; + +const CS_HANDLE_REQUEST_BODY_MODULE_CODE: &str = r#" +using System; +using System.Collections.Generic; +using System.Text; +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.HttpHandler] + public static HttpResponse ReverseBytes(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + var reversed = request.Body.ToBytes(); + Array.Reverse(reversed); + return BytesResponse(200, reversed); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse ReverseWords(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + string body; + try + { + body = new UTF8Encoding(false, true).GetString(request.Body.ToBytes()); + } + catch (DecoderFallbackException) + { + return TextResponse(400, "request body must be valid UTF-8"); + } + + var reversed = string.Join(" ", body.Split(' ').Reverse()); + return TextResponse(200, reversed); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Post("/reverse-bytes", Handlers.ReverseBytes) + .Post("/reverse-words", Handlers.ReverseWords); + + private static HttpResponse BytesResponse(ushort statusCode, byte[] body) => + new(statusCode, HttpVersion.Http11, new List(), new HttpBody(body)); + + private static HttpResponse TextResponse(ushort statusCode, string body) => + new(statusCode, HttpVersion.Http11, new List(), HttpBody.FromString(body)); +} +"#; + 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()) @@ -244,19 +581,36 @@ fn extract_rust_code_blocks(doc_path: &Path) -> String { assert!( !blocks.is_empty(), - "expected at least one rust code block in {}", + "expected at least one {} code block in {}", + language_name, doc_path.display() ); 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 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) +} - let base = format!("{}/v1/database/{}/route", test.server_url, identity); +fn csharp_http_test(name: &str, module_code: &str) -> (Smoketest, String) { + let mut test = Smoketest::builder().autopublish(false).build(); + let identity = test.publish_csharp_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 = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let resp = client.get(format!("{base}/get")).send().expect("get failed"); @@ -311,10 +665,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 +679,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 = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let payload = b"hello from the PR example".to_vec(); @@ -368,14 +715,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 = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let resp = client.get(format!("{base}/foo")).send().expect("foo failed"); @@ -398,14 +739,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 = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let resp = client.get(base.clone()).send().expect("empty root failed"); @@ -417,12 +752,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 = route_base(server_url, identity); let url = format!("{base}/echo-uri?alpha=beta"); let client = reqwest::blocking::Client::new(); @@ -431,14 +762,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 = route_base(server_url, identity); let client = reqwest::blocking::Client::new(); let resp = client @@ -502,11 +827,100 @@ 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 csharp_http_routes_end_to_end() { + require_dotnet!(); + let (test, identity) = csharp_http_test("http-routes-csharp-basic", CS_MODULE_CODE); + assert_http_routes_end_to_end(&test.server_url, &identity); +} + +#[test] +fn csharp_http_routes_pr_example_round_trip() { + require_dotnet!(); + let (test, identity) = csharp_http_test("http-routes-csharp-example", CS_EXAMPLE_MODULE_CODE); + assert_http_routes_pr_example_round_trip(&test.server_url, &identity); +} + +#[test] +fn csharp_http_routes_are_strict_for_non_root_paths() { + require_dotnet!(); + let (test, identity) = csharp_http_test( + "http-routes-csharp-strict-non-root", + CS_STRICT_NON_ROOT_ROUTING_MODULE_CODE, + ); + assert_http_routes_are_strict_for_non_root_paths(&test.server_url, &identity); +} + +#[test] +fn csharp_http_routes_are_strict_for_root_paths() { + require_dotnet!(); + let (test, identity) = csharp_http_test( + "http-routes-csharp-strict-root", + CS_STRICT_ROOT_ROUTING_MODULE_CODE, + ); + assert_http_routes_are_strict_for_root_paths(&test.server_url, &identity); +} + +#[test] +fn csharp_http_handler_observes_full_external_uri() { + require_dotnet!(); + let (test, identity) = csharp_http_test("http-routes-csharp-full-uri", CS_FULL_URI_MODULE_CODE); + assert_http_handler_observes_full_external_uri(&test.server_url, &identity); +} + +#[test] +fn csharp_handle_request_body() { + require_dotnet!(); + let (test, identity) = csharp_http_test( + "http-routes-csharp-request-body", + CS_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"); @@ -518,3 +932,22 @@ 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 csharp_http_handlers_tutorial_say_hello_route_works() { + require_dotnet!(); + let module_code = extract_code_blocks( + &workspace_root().join("docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md"), + r"```csharp\n([\s\S]*?)\n```", + "csharp", + ); + let (test, identity) = csharp_http_test("http-handlers-docs-csharp", &module_code); + + let url = format!("{}/v1/database/{}/route/say-hello", test.server_url, identity); + 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..e3b92969d2f 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,42 @@ fn say_hello(_ctx: &mut HandlerContext, _req: Request) -> Response { } ``` + + + +HTTP handlers in C# are currently unstable. To use them, add `#pragma warning disable STDB_UNSTABLE` at the top of your file. + +Define an HTTP handler by annotating a method with `[SpacetimeDB.HttpHandler]`. + +The method must accept exactly two arguments: + +1. A `SpacetimeDB.HandlerContext`. +2. A `SpacetimeDB.HttpRequest`. + +The method must return a `SpacetimeDB.HttpResponse`. + +```csharp +using System.Collections.Generic; +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE +public static partial class Module +{ + [SpacetimeDB.HttpHandler] + public static HttpResponse SayHello(HandlerContext ctx, HttpRequest request) + { + _ = ctx; + _ = request; + return new HttpResponse( + 200, + HttpVersion.Http11, + new List(), + HttpBody.FromString("Hello!") + ); + } +} +``` + @@ -51,6 +110,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 +150,27 @@ 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 method annotated with `[SpacetimeDB.HttpRouter]`. + +```csharp +public static partial class Module +{ + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Get("/say-hello", Handlers.SayHello); +} +``` + +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. + From a30539f0ecbbb32c52957cf8a4e8800e3339a4d1 Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Thu, 14 May 2026 14:42:09 -0700 Subject: [PATCH 3/5] Linting fixes --- crates/bindings-csharp/Codegen/Module.cs | 34 +++++------- .../Runtime.Tests/RouterTests.cs | 54 +++++++++---------- .../bindings-csharp/Runtime/HandlerContext.cs | 23 ++++---- crates/bindings-csharp/Runtime/Http.cs | 4 +- .../Runtime/Internal/Module.cs | 19 ++++--- .../Runtime/ProcedureContext.cs | 23 ++++---- crates/bindings-csharp/Runtime/Router.cs | 10 ++-- .../tests/smoketests/http_routes.rs | 10 +--- 8 files changed, 87 insertions(+), 90 deletions(-) diff --git a/crates/bindings-csharp/Codegen/Module.cs b/crates/bindings-csharp/Codegen/Module.cs index d118adfd395..2fa2bad976b 100644 --- a/crates/bindings-csharp/Codegen/Module.cs +++ b/crates/bindings-csharp/Codegen/Module.cs @@ -1772,17 +1772,14 @@ public HttpHandlerDeclaration(GeneratorAttributeSyntaxContext context, DiagRepor if ( method.Parameters.FirstOrDefault()?.Type - is not INamedTypeSymbol - { - Name: "HandlerContext", - Arity: 0, - ContainingType: null, - ContainingNamespace: + is not INamedTypeSymbol { - Name: "SpacetimeDB", - ContainingNamespace: { IsGlobalNamespace: true } + Name: "HandlerContext", + Arity: 0, + ContainingType: null, + ContainingNamespace: + { Name: "SpacetimeDB", ContainingNamespace: { IsGlobalNamespace: true } } } - } && methodSyntax.ParameterList.Parameters.FirstOrDefault()?.Type is not IdentifierNameSyntax { Identifier.ValueText: "HandlerContext" } && methodSyntax.ParameterList.Parameters.FirstOrDefault()?.Type @@ -1794,12 +1791,11 @@ is not QualifiedNameSyntax && methodSyntax.ParameterList.Parameters.FirstOrDefault()?.Type is not QualifiedNameSyntax { - Left: - AliasQualifiedNameSyntax - { - Alias.Identifier.ValueText: "global", - Name: IdentifierNameSyntax { Identifier.ValueText: "SpacetimeDB" } - }, + Left: AliasQualifiedNameSyntax + { + Alias.Identifier.ValueText: "global", + Name: IdentifierNameSyntax { Identifier.ValueText: "SpacetimeDB" } + }, Right: IdentifierNameSyntax { Identifier.ValueText: "HandlerContext" } } ) @@ -2036,13 +2032,7 @@ TColumnDefaultValues columnDefaultValues ( ( ( - ( - ( - (TTableAccessors, TSettings), - TTableDecls - ), - TReducers - ), + (((TTableAccessors, TSettings), TTableDecls), TReducers), TProcedures ), THttpHandlers diff --git a/crates/bindings-csharp/Runtime.Tests/RouterTests.cs b/crates/bindings-csharp/Runtime.Tests/RouterTests.cs index 341457ae41d..3bd57d32508 100644 --- a/crates/bindings-csharp/Runtime.Tests/RouterTests.cs +++ b/crates/bindings-csharp/Runtime.Tests/RouterTests.cs @@ -13,7 +13,8 @@ private static class TestHandlers [Fact] public void AllowsDistinctMethodsOnSamePath() { - var router = Router.New() + var router = Router + .New() .Get("/hooks", TestHandlers.GetHandler) .Post("/hooks", TestHandlers.PostHandler); @@ -24,9 +25,11 @@ public void AllowsDistinctMethodsOnSamePath() public void RejectsAnyConflictOnSamePath() { var ex = Assert.Throws( - () => Router.New() - .Any("/hooks", TestHandlers.GetHandler) - .Get("/hooks", TestHandlers.PostHandler) + () => + Router + .New() + .Any("/hooks", TestHandlers.GetHandler) + .Get("/hooks", TestHandlers.PostHandler) ); Assert.Contains("Route conflict", ex.Message); @@ -45,10 +48,7 @@ public void RejectsInvalidPathCharacters() [Fact] public void NestJoinsPathsWithoutDoubleSlash() { - var router = Router.New().Nest( - "/api", - Router.New().Get("/hooks", TestHandlers.GetHandler) - ); + var router = Router.New().Nest("/api", Router.New().Get("/hooks", TestHandlers.GetHandler)); Assert.NotNull(router); } @@ -57,11 +57,11 @@ public void NestJoinsPathsWithoutDoubleSlash() public void NestRejectsExistingSiblingPrefix() { var ex = Assert.Throws( - () => Router.New().Get("/apiv2", TestHandlers.GetHandler) - .Nest( - "/api", - Router.New().Get("/hooks", TestHandlers.PostHandler) - ) + () => + Router + .New() + .Get("/apiv2", TestHandlers.GetHandler) + .Nest("/api", Router.New().Get("/hooks", TestHandlers.PostHandler)) ); Assert.Contains("Cannot nest router", ex.Message); @@ -71,11 +71,11 @@ public void NestRejectsExistingSiblingPrefix() public void NestRejectsExistingRouteAtNestedPrefix() { var ex = Assert.Throws( - () => Router.New().Get("/api", TestHandlers.GetHandler) - .Nest( - "/api", - Router.New().Get("/hooks", TestHandlers.PostHandler) - ) + () => + Router + .New() + .Get("/api", TestHandlers.GetHandler) + .Nest("/api", Router.New().Get("/hooks", TestHandlers.PostHandler)) ); Assert.Contains("Cannot nest router", ex.Message); @@ -85,21 +85,21 @@ public void NestRejectsExistingRouteAtNestedPrefix() public void NestStillRejectsExactRouteConflicts() { var ex = Assert.Throws( - () => Router.New().Get("/api/hooks", TestHandlers.GetHandler) - .Nest( - "/api", - Router.New().Get("/hooks", TestHandlers.PostHandler) - ) + () => + Router + .New() + .Get("/api/hooks", TestHandlers.GetHandler) + .Nest("/api", Router.New().Get("/hooks", TestHandlers.PostHandler)) ); Assert.Contains("Cannot nest router", ex.Message); } - private sealed class TestHandlerContext() - : HandlerContextBase(new System.Random(), default) + private sealed class TestHandlerContext() : HandlerContextBase(new System.Random(), default) { - protected override HandlerTxContextBase CreateTxContext(SpacetimeDB.Internal.TxContext inner) => - throw new NotSupportedException(); + protected override HandlerTxContextBase CreateTxContext( + SpacetimeDB.Internal.TxContext inner + ) => throw new NotSupportedException(); protected internal override LocalBase CreateLocal() => throw new NotSupportedException(); } diff --git a/crates/bindings-csharp/Runtime/HandlerContext.cs b/crates/bindings-csharp/Runtime/HandlerContext.cs index e01f8f60b69..8ad7fe14239 100644 --- a/crates/bindings-csharp/Runtime/HandlerContext.cs +++ b/crates/bindings-csharp/Runtime/HandlerContext.cs @@ -22,15 +22,14 @@ protected HandlerContextBase(Random random, Timestamp time) txState = new( random, time, - timestamp => - new Internal.TxContext( - CreateLocal(), - default, - null, - timestamp, - AuthCtx.BuildFromSystemTables(null, default), - random - ), + timestamp => new Internal.TxContext( + CreateLocal(), + default, + null, + timestamp, + AuthCtx.BuildFromSystemTables(null, default), + random + ), inner => CreateTxContext(inner) ); } @@ -38,7 +37,8 @@ protected HandlerContextBase(Random random, Timestamp time) protected abstract HandlerTxContextBase CreateTxContext(Internal.TxContext inner); protected internal abstract LocalBase CreateLocal(); - public Internal.TxContext EnterTxContext(long timestampMicros) => txState.EnterTxContext(timestampMicros); + public Internal.TxContext EnterTxContext(long timestampMicros) => + txState.EnterTxContext(timestampMicros); public void ExitTxContext() => txState.ExitTxContext(); @@ -76,7 +76,7 @@ Func> body ? TxOutcome.Success(outcome.Value!) : TxOutcome.Failure( outcome.Error - ?? new InvalidOperationException("Transaction failed without an error object.") + ?? new InvalidOperationException("Transaction failed without an error object.") ); } } @@ -86,6 +86,7 @@ public abstract class HandlerTxContextBase(Internal.TxContext inner) : IRefresha internal Internal.TxContext Inner { get; private set; } = inner; internal void Refresh(Internal.TxContext inner) => Inner = inner; + void IRefreshableTxContext.Refresh(Internal.TxContext inner) => Refresh(inner); public LocalBase Db => (LocalBase)Inner.Db; diff --git a/crates/bindings-csharp/Runtime/Http.cs b/crates/bindings-csharp/Runtime/Http.cs index 3debdd52966..be98b704fcc 100644 --- a/crates/bindings-csharp/Runtime/Http.cs +++ b/crates/bindings-csharp/Runtime/Http.cs @@ -423,7 +423,9 @@ internal static HttpRequest FromWire(HttpRequestWire requestWire, byte[] body) = { Uri = requestWire.Uri, Method = FromWireMethod(requestWire.Method), - Headers = requestWire.Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)).ToList(), + Headers = requestWire + .Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)) + .ToList(), Body = new HttpBody(body), Version = FromWireVersion(requestWire.Version), }; diff --git a/crates/bindings-csharp/Runtime/Internal/Module.cs b/crates/bindings-csharp/Runtime/Internal/Module.cs index edeada80d44..098ec392a01 100644 --- a/crates/bindings-csharp/Runtime/Internal/Module.cs +++ b/crates/bindings-csharp/Runtime/Internal/Module.cs @@ -237,7 +237,8 @@ private static Func< >? newReducerContext = null; private static Func? newViewContext = null; private static Func? newAnonymousViewContext = null; - private static Func? newHandlerContext = null; + private static Func? newHandlerContext = + null; private static Func< Identity, @@ -324,7 +325,11 @@ public static void RegisterHttpRouter(SpacetimeDB.Router router) { foreach (var route in router.GetRoutes()) { - if (!httpHandlers.Any(handler => handler.MakeHandlerDef().SourceName == route.HandlerFunction)) + if ( + !httpHandlers.Any(handler => + handler.MakeHandlerDef().SourceName == route.HandlerFunction + ) + ) { throw new ArgumentException( $"HTTP router references unknown handler `{route.HandlerFunction}`", @@ -597,12 +602,12 @@ BytesSink responseBodySink throw new Exception("Unrecognised extra bytes in the HTTP handler request"); } - var response = httpHandlers[(int)id].Invoke( - ctx, - SpacetimeDB.HttpClient.FromWire(requestWire, requestBody.Consume()) - ); + var response = httpHandlers[(int)id] + .Invoke(ctx, SpacetimeDB.HttpClient.FromWire(requestWire, requestBody.Consume())); var (responseWire, responseBody) = SpacetimeDB.HttpClient.ToWire(response); - responseSink.Write(IStructuralReadWrite.ToBytes(new HttpResponseWire.BSATN(), responseWire)); + responseSink.Write( + IStructuralReadWrite.ToBytes(new HttpResponseWire.BSATN(), responseWire) + ); responseBodySink.Write(responseBody); return Errno.OK; diff --git a/crates/bindings-csharp/Runtime/ProcedureContext.cs b/crates/bindings-csharp/Runtime/ProcedureContext.cs index f8d81e79f1e..9c5a197aa1e 100644 --- a/crates/bindings-csharp/Runtime/ProcedureContext.cs +++ b/crates/bindings-csharp/Runtime/ProcedureContext.cs @@ -33,15 +33,14 @@ Timestamp time txState = new( random, time, - timestamp => - new Internal.TxContext( - CreateLocal(), - Sender, - ConnectionId, - timestamp, - SenderAuth, - random - ), + timestamp => new Internal.TxContext( + CreateLocal(), + Sender, + ConnectionId, + timestamp, + SenderAuth, + random + ), inner => CreateTxContext(inner) ); } @@ -49,7 +48,8 @@ Timestamp time protected abstract ProcedureTxContextBase CreateTxContext(Internal.TxContext inner); protected internal abstract LocalBase CreateLocal(); - public Internal.TxContext EnterTxContext(long timestampMicros) => txState.EnterTxContext(timestampMicros); + public Internal.TxContext EnterTxContext(long timestampMicros) => + txState.EnterTxContext(timestampMicros); public void ExitTxContext() => txState.ExitTxContext(); @@ -90,7 +90,7 @@ Func> body ? TxOutcome.Success(outcome.Value!) : TxOutcome.Failure( outcome.Error - ?? new InvalidOperationException("Transaction failed without an error object.") + ?? new InvalidOperationException("Transaction failed without an error object.") ); } } @@ -100,6 +100,7 @@ public abstract class ProcedureTxContextBase(Internal.TxContext inner) : IRefres internal Internal.TxContext Inner { get; private set; } = inner; internal void Refresh(Internal.TxContext inner) => Inner = inner; + void IRefreshableTxContext.Refresh(Internal.TxContext inner) => Refresh(inner); public LocalBase Db => (LocalBase)Inner.Db; diff --git a/crates/bindings-csharp/Runtime/Router.cs b/crates/bindings-csharp/Runtime/Router.cs index 2c71ea366db..e146bd5057d 100644 --- a/crates/bindings-csharp/Runtime/Router.cs +++ b/crates/bindings-csharp/Runtime/Router.cs @@ -4,12 +4,15 @@ namespace SpacetimeDB; using System.Collections.Generic; using Internal; -public readonly record struct Handler(string FunctionName) -{ } +public readonly record struct Handler(string FunctionName) { } public sealed class Router { - internal readonly record struct RouteSpec(MethodOrAny Method, string Path, string HandlerFunction); + internal readonly record struct RouteSpec( + MethodOrAny Method, + string Path, + string HandlerFunction + ); private const string AcceptableRoutePathCharsHumanDescription = "ASCII lowercase letters, digits and `-_~/`"; @@ -108,6 +111,7 @@ string handlerFunction routes.Add(candidate); } + private static string JoinPaths(string prefix, string suffix) { if (prefix == "/") diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 6495d7b24df..abd065ceecc 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -890,10 +890,7 @@ fn csharp_http_routes_are_strict_for_non_root_paths() { #[test] fn csharp_http_routes_are_strict_for_root_paths() { require_dotnet!(); - let (test, identity) = csharp_http_test( - "http-routes-csharp-strict-root", - CS_STRICT_ROOT_ROUTING_MODULE_CODE, - ); + let (test, identity) = csharp_http_test("http-routes-csharp-strict-root", CS_STRICT_ROOT_ROUTING_MODULE_CODE); assert_http_routes_are_strict_for_root_paths(&test.server_url, &identity); } @@ -907,10 +904,7 @@ fn csharp_http_handler_observes_full_external_uri() { #[test] fn csharp_handle_request_body() { require_dotnet!(); - let (test, identity) = csharp_http_test( - "http-routes-csharp-request-body", - CS_HANDLE_REQUEST_BODY_MODULE_CODE, - ); + let (test, identity) = csharp_http_test("http-routes-csharp-request-body", CS_HANDLE_REQUEST_BODY_MODULE_CODE); assert_handle_request_body(&test.server_url, &identity); } From 1fedd883bcca7ea15e4823718b61323177040db0 Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Fri, 15 May 2026 15:35:21 -0700 Subject: [PATCH 4/5] Cleaned up mistaken use of discarding --- .../tests/smoketests/http_routes.rs | 28 ------------------- .../00200-functions/00600-HTTP-handlers.md | 2 -- 2 files changed, 30 deletions(-) diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index abd065ceecc..aab3e0bcbe3 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -252,15 +252,12 @@ public static partial class Module [SpacetimeDB.HttpHandler] public static HttpResponse GetSimple(HandlerContext ctx, HttpRequest request) { - _ = ctx; - _ = request; return TextResponse(200, "ok"); } [SpacetimeDB.HttpHandler] public static HttpResponse PostInsert(HandlerContext ctx, HttpRequest request) { - _ = request; ctx.WithTx((HandlerTxContext tx) => { var id = tx.Db.Entry.Count; @@ -273,7 +270,6 @@ public static partial class Module [SpacetimeDB.HttpHandler] public static HttpResponse GetCount(HandlerContext ctx, HttpRequest request) { - _ = request; var count = ctx.WithTx((HandlerTxContext tx) => tx.Db.Entry.Count); return TextResponse(200, count.ToString()); } @@ -281,23 +277,18 @@ public static partial class Module [SpacetimeDB.HttpHandler] public static HttpResponse AnyHandler(HandlerContext ctx, HttpRequest request) { - _ = ctx; - _ = request; return TextResponse(200, "any"); } [SpacetimeDB.HttpHandler] public static HttpResponse HeaderEcho(HandlerContext ctx, HttpRequest request) { - _ = ctx; return TextResponse(200, HeaderValueUtf8(request, "x-echo")); } [SpacetimeDB.HttpHandler] public static HttpResponse SetResponseHeader(HandlerContext ctx, HttpRequest request) { - _ = ctx; - _ = request; return new HttpResponse( 200, HttpVersion.Http11, @@ -309,16 +300,12 @@ public static partial class Module [SpacetimeDB.HttpHandler] public static HttpResponse BodyHandler(HandlerContext ctx, HttpRequest request) { - _ = ctx; - _ = request; return TextResponse(200, "non-empty"); } [SpacetimeDB.HttpHandler] public static HttpResponse Teapot(HandlerContext ctx, HttpRequest request) { - _ = ctx; - _ = request; return TextResponse(418, "teapot"); } @@ -419,32 +406,24 @@ public static partial class Module [SpacetimeDB.HttpHandler] public static HttpResponse EmptyRoot(HandlerContext ctx, HttpRequest request) { - _ = ctx; - _ = request; return TextResponse("empty"); } [SpacetimeDB.HttpHandler] public static HttpResponse SlashRoot(HandlerContext ctx, HttpRequest request) { - _ = ctx; - _ = request; return TextResponse("slash"); } [SpacetimeDB.HttpHandler] public static HttpResponse Foo(HandlerContext ctx, HttpRequest request) { - _ = ctx; - _ = request; return TextResponse("foo"); } [SpacetimeDB.HttpHandler] public static HttpResponse FooSlash(HandlerContext ctx, HttpRequest request) { - _ = ctx; - _ = request; return TextResponse("foo-slash"); } @@ -470,16 +449,12 @@ public static partial class Module [SpacetimeDB.HttpHandler] public static HttpResponse Foo(HandlerContext ctx, HttpRequest request) { - _ = ctx; - _ = request; return TextResponse("foo"); } [SpacetimeDB.HttpHandler] public static HttpResponse FooSlash(HandlerContext ctx, HttpRequest request) { - _ = ctx; - _ = request; return TextResponse("foo-slash"); } @@ -503,7 +478,6 @@ public static partial class Module [SpacetimeDB.HttpHandler] public static HttpResponse EchoUri(HandlerContext ctx, HttpRequest request) { - _ = ctx; return new HttpResponse( 200, HttpVersion.Http11, @@ -529,7 +503,6 @@ public static partial class Module [SpacetimeDB.HttpHandler] public static HttpResponse ReverseBytes(HandlerContext ctx, HttpRequest request) { - _ = ctx; var reversed = request.Body.ToBytes(); Array.Reverse(reversed); return BytesResponse(200, reversed); @@ -538,7 +511,6 @@ public static partial class Module [SpacetimeDB.HttpHandler] public static HttpResponse ReverseWords(HandlerContext ctx, HttpRequest request) { - _ = ctx; string body; try { 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 e3b92969d2f..aab6d67690f 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 @@ -90,8 +90,6 @@ public static partial class Module [SpacetimeDB.HttpHandler] public static HttpResponse SayHello(HandlerContext ctx, HttpRequest request) { - _ = ctx; - _ = request; return new HttpResponse( 200, HttpVersion.Http11, From 30a8263a94236ccf267878f24cc7b6fb2e351cb7 Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Fri, 15 May 2026 17:17:51 -0700 Subject: [PATCH 5/5] Codegen test cleanup --- .../ExtraCompilationErrors.verified.txt | 2 +- .../diag/snapshots/Module#FFI.verified.cs | 73 +++++++++++++++++++ .../snapshots/Module#FFI.verified.cs | 72 ++++++++++++++++++ .../server/snapshots/Module#FFI.verified.cs | 73 +++++++++++++++++++ crates/bindings-csharp/Codegen/Diag.cs | 36 ++++----- 5 files changed, 237 insertions(+), 19 deletions(-) diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/ExtraCompilationErrors.verified.txt b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/ExtraCompilationErrors.verified.txt index c732156f772..8d65f041f30 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/ExtraCompilationErrors.verified.txt +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/ExtraCompilationErrors.verified.txt @@ -23,7 +23,7 @@ } }, {/* -SpacetimeDB.Internal.Module.RegisterTable(); + SpacetimeDB.Internal.Module.RegisterClientVisibilityFilter(global::Module.MY_FILTER); ^^^^^^^^^ SpacetimeDB.Internal.Module.RegisterClientVisibilityFilter(global::Module.MY_FOURTH_FILTER); diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs index 3ce055b4bc7..19be3972347 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs @@ -645,6 +645,8 @@ public readonly partial struct QueryBuilder ); } + public static class Handlers { } + public sealed record ReducerContext : DbContext, Internal.IReducerContext { public readonly Identity Sender; @@ -804,6 +806,46 @@ public Uuid NewUuidV7() } } + public sealed partial class HandlerContext : global::SpacetimeDB.HandlerContextBase + { + private readonly Local _db = new(); + + internal HandlerContext(Random random, Timestamp time) + : base(random, time) { } + + protected override global::SpacetimeDB.LocalBase CreateLocal() => _db; + + protected override global::SpacetimeDB.HandlerTxContextBase CreateTxContext( + Internal.TxContext inner + ) => _cached ??= new HandlerTxContext(inner); + + private HandlerTxContext? _cached; + + [Experimental("STDB_UNSTABLE")] + public TResult WithTx(Func body) => + base.WithTx(tx => body((HandlerTxContext)tx)); + + [Experimental("STDB_UNSTABLE")] + public TxOutcome TryWithTx( + Func> body + ) + where TError : Exception => base.TryWithTx(tx => body((HandlerTxContext)tx)); + + public Uuid NewUuidV4() + { + var bytes = new byte[16]; + Rng.NextBytes(bytes); + return Uuid.FromRandomBytesV4(bytes); + } + + public Uuid NewUuidV7() + { + var bytes = new byte[4]; + Rng.NextBytes(bytes); + return Uuid.FromCounterV7(ref CounterUuid, Timestamp, bytes); + } + } + [Experimental("STDB_UNSTABLE")] public sealed class ProcedureTxContext : global::SpacetimeDB.ProcedureTxContextBase { @@ -813,6 +855,15 @@ internal ProcedureTxContext(Internal.TxContext inner) public new Local Db => (Local)base.Db; } + [Experimental("STDB_UNSTABLE")] + public sealed class HandlerTxContext : global::SpacetimeDB.HandlerTxContextBase + { + internal HandlerTxContext(Internal.TxContext inner) + : base(inner) { } + + public new Local Db => (Local)base.Db; + } + public sealed class Local : global::SpacetimeDB.LocalBase { public global::SpacetimeDB.Internal.TableHandles.Player Player => new(); @@ -3180,6 +3231,9 @@ public static void Main() "TestCanonicalNameWithoutAccessor" ); + SpacetimeDB.Internal.Module.SetHandlerContextConstructor( + (random, time) => new SpacetimeDB.HandlerContext(random, time) + ); var __memoryStream = new MemoryStream(); var __writer = new BinaryWriter(__memoryStream); @@ -3252,6 +3306,7 @@ public static void Main() global::TestUniqueNotEquatable, SpacetimeDB.Internal.TableHandles.TestUniqueNotEquatable >(); + SpacetimeDB.Internal.Module.RegisterClientVisibilityFilter(global::Module.MY_FILTER); SpacetimeDB.Internal.Module.RegisterClientVisibilityFilter(global::Module.MY_FOURTH_FILTER); SpacetimeDB.Internal.Module.RegisterClientVisibilityFilter(global::Module.MY_SECOND_FILTER); @@ -3523,6 +3578,24 @@ SpacetimeDB.Internal.BytesSink result_sink result_sink ); + [UnmanagedCallersOnly(EntryPoint = "__call_http_handler__")] + public static SpacetimeDB.Internal.Errno __call_http_handler__( + uint id, + SpacetimeDB.Timestamp timestamp, + SpacetimeDB.Internal.BytesSource request, + SpacetimeDB.Internal.BytesSource request_body, + SpacetimeDB.Internal.BytesSink response_sink, + SpacetimeDB.Internal.BytesSink response_body_sink + ) => + SpacetimeDB.Internal.Module.__call_http_handler__( + id, + timestamp, + request, + request_body, + response_sink, + response_body_sink + ); + [UnmanagedCallersOnly(EntryPoint = "__call_view__")] public static SpacetimeDB.Internal.Errno __call_view__( uint id, diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs index a9774bfc69e..5807faa45d6 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs @@ -47,6 +47,8 @@ public readonly partial struct QueryBuilder new("DemoTable", new DemoTableCols("DemoTable"), new DemoTableIxCols("DemoTable")); } + public static class Handlers { } + public sealed record ReducerContext : DbContext, Internal.IReducerContext { public readonly Identity Sender; @@ -206,6 +208,46 @@ public Uuid NewUuidV7() } } + public sealed partial class HandlerContext : global::SpacetimeDB.HandlerContextBase + { + private readonly Local _db = new(); + + internal HandlerContext(Random random, Timestamp time) + : base(random, time) { } + + protected override global::SpacetimeDB.LocalBase CreateLocal() => _db; + + protected override global::SpacetimeDB.HandlerTxContextBase CreateTxContext( + Internal.TxContext inner + ) => _cached ??= new HandlerTxContext(inner); + + private HandlerTxContext? _cached; + + [Experimental("STDB_UNSTABLE")] + public TResult WithTx(Func body) => + base.WithTx(tx => body((HandlerTxContext)tx)); + + [Experimental("STDB_UNSTABLE")] + public TxOutcome TryWithTx( + Func> body + ) + where TError : Exception => base.TryWithTx(tx => body((HandlerTxContext)tx)); + + public Uuid NewUuidV4() + { + var bytes = new byte[16]; + Rng.NextBytes(bytes); + return Uuid.FromRandomBytesV4(bytes); + } + + public Uuid NewUuidV7() + { + var bytes = new byte[4]; + Rng.NextBytes(bytes); + return Uuid.FromCounterV7(ref CounterUuid, Timestamp, bytes); + } + } + [Experimental("STDB_UNSTABLE")] public sealed class ProcedureTxContext : global::SpacetimeDB.ProcedureTxContextBase { @@ -215,6 +257,15 @@ internal ProcedureTxContext(Internal.TxContext inner) public new Local Db => (Local)base.Db; } + [Experimental("STDB_UNSTABLE")] + public sealed class HandlerTxContext : global::SpacetimeDB.HandlerTxContextBase + { + internal HandlerTxContext(Internal.TxContext inner) + : base(inner) { } + + public new Local Db => (Local)base.Db; + } + public sealed class Local : global::SpacetimeDB.LocalBase { public global::SpacetimeDB.Internal.TableHandles.DemoTable DemoTable => new(); @@ -556,6 +607,9 @@ public static void Main() "canonical_index" ); + SpacetimeDB.Internal.Module.SetHandlerContextConstructor( + (random, time) => new SpacetimeDB.HandlerContext(random, time) + ); var __memoryStream = new MemoryStream(); var __writer = new BinaryWriter(__memoryStream); @@ -631,6 +685,24 @@ SpacetimeDB.Internal.BytesSink result_sink result_sink ); + [UnmanagedCallersOnly(EntryPoint = "__call_http_handler__")] + public static SpacetimeDB.Internal.Errno __call_http_handler__( + uint id, + SpacetimeDB.Timestamp timestamp, + SpacetimeDB.Internal.BytesSource request, + SpacetimeDB.Internal.BytesSource request_body, + SpacetimeDB.Internal.BytesSink response_sink, + SpacetimeDB.Internal.BytesSink response_body_sink + ) => + SpacetimeDB.Internal.Module.__call_http_handler__( + id, + timestamp, + request, + request_body, + response_sink, + response_body_sink + ); + [UnmanagedCallersOnly(EntryPoint = "__call_view__")] public static SpacetimeDB.Internal.Errno __call_view__( uint id, diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs index 4632875e05f..07d4b5bb67e 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs @@ -489,6 +489,8 @@ public readonly partial struct QueryBuilder ); } + public static class Handlers { } + public sealed record ReducerContext : DbContext, Internal.IReducerContext { public readonly Identity Sender; @@ -648,6 +650,46 @@ public Uuid NewUuidV7() } } + public sealed partial class HandlerContext : global::SpacetimeDB.HandlerContextBase + { + private readonly Local _db = new(); + + internal HandlerContext(Random random, Timestamp time) + : base(random, time) { } + + protected override global::SpacetimeDB.LocalBase CreateLocal() => _db; + + protected override global::SpacetimeDB.HandlerTxContextBase CreateTxContext( + Internal.TxContext inner + ) => _cached ??= new HandlerTxContext(inner); + + private HandlerTxContext? _cached; + + [Experimental("STDB_UNSTABLE")] + public TResult WithTx(Func body) => + base.WithTx(tx => body((HandlerTxContext)tx)); + + [Experimental("STDB_UNSTABLE")] + public TxOutcome TryWithTx( + Func> body + ) + where TError : Exception => base.TryWithTx(tx => body((HandlerTxContext)tx)); + + public Uuid NewUuidV4() + { + var bytes = new byte[16]; + Rng.NextBytes(bytes); + return Uuid.FromRandomBytesV4(bytes); + } + + public Uuid NewUuidV7() + { + var bytes = new byte[4]; + Rng.NextBytes(bytes); + return Uuid.FromCounterV7(ref CounterUuid, Timestamp, bytes); + } + } + [Experimental("STDB_UNSTABLE")] public sealed class ProcedureTxContext : global::SpacetimeDB.ProcedureTxContextBase { @@ -657,6 +699,15 @@ internal ProcedureTxContext(Internal.TxContext inner) public new Local Db => (Local)base.Db; } + [Experimental("STDB_UNSTABLE")] + public sealed class HandlerTxContext : global::SpacetimeDB.HandlerTxContextBase + { + internal HandlerTxContext(Internal.TxContext inner) + : base(inner) { } + + public new Local Db => (Local)base.Db; + } + public sealed class Local : global::SpacetimeDB.LocalBase { internal global::SpacetimeDB.Internal.TableHandles.BTreeMultiColumn BTreeMultiColumn => @@ -2446,6 +2497,9 @@ public static void Main() (identity, connectionId, random, time) => new SpacetimeDB.ProcedureContext(identity, connectionId, random, time) ); + SpacetimeDB.Internal.Module.SetHandlerContextConstructor( + (random, time) => new SpacetimeDB.HandlerContext(random, time) + ); var __memoryStream = new MemoryStream(); var __writer = new BinaryWriter(__memoryStream); @@ -2495,6 +2549,7 @@ public static void Main() global::Timers.SendMessageTimer, SpacetimeDB.Internal.TableHandles.SendMessageTimer >(); + SpacetimeDB.Internal.Module.RegisterClientVisibilityFilter( global::Module.ALL_PUBLIC_TABLES ); @@ -2558,6 +2613,24 @@ SpacetimeDB.Internal.BytesSink result_sink result_sink ); + [UnmanagedCallersOnly(EntryPoint = "__call_http_handler__")] + public static SpacetimeDB.Internal.Errno __call_http_handler__( + uint id, + SpacetimeDB.Timestamp timestamp, + SpacetimeDB.Internal.BytesSource request, + SpacetimeDB.Internal.BytesSource request_body, + SpacetimeDB.Internal.BytesSink response_sink, + SpacetimeDB.Internal.BytesSink response_body_sink + ) => + SpacetimeDB.Internal.Module.__call_http_handler__( + id, + timestamp, + request, + request_body, + response_sink, + response_body_sink + ); + [UnmanagedCallersOnly(EntryPoint = "__call_view__")] public static SpacetimeDB.Internal.Errno __call_view__( uint id, diff --git a/crates/bindings-csharp/Codegen/Diag.cs b/crates/bindings-csharp/Codegen/Diag.cs index f60b67e45c8..928b7a71635 100644 --- a/crates/bindings-csharp/Codegen/Diag.cs +++ b/crates/bindings-csharp/Codegen/Diag.cs @@ -259,15 +259,6 @@ string typeName _ => Location.None ); - public static readonly ErrorDescriptor> DuplicateHttpRouters = - new( - group, - "Multiple [SpacetimeDB.HttpRouter] declarations", - fullNames => - $"[SpacetimeDB.HttpRouter] is declared multiple times: {string.Join(", ", fullNames)}", - _ => Location.None - ); - public static readonly ErrorDescriptor TableLevelIndexMissingAccessor = new( group, @@ -286,15 +277,6 @@ string typeName method => method.ParameterList ); - public static readonly ErrorDescriptor HttpHandlerSignature = - new( - group, - "HTTP handlers must be non-generic methods with exactly two parameters", - method => - $"HTTP handler method {method.Identifier} must be non-generic and take exactly two parameters.", - method => method.ParameterList - ); - public static readonly ErrorDescriptor HttpHandlerRequestParam = new( group, @@ -333,4 +315,22 @@ string prefix $"HTTP handler method {ctx.method.Identifier} starts with '{ctx.prefix}', which is a reserved prefix.", ctx => ctx.method.Identifier ); + + public static readonly ErrorDescriptor> DuplicateHttpRouters = + new( + group, + "Multiple [SpacetimeDB.HttpRouter] declarations", + fullNames => + $"[SpacetimeDB.HttpRouter] is declared multiple times: {string.Join(", ", fullNames)}", + _ => Location.None + ); + + public static readonly ErrorDescriptor HttpHandlerSignature = + new( + group, + "HTTP handlers must be non-generic methods with exactly two parameters", + method => + $"HTTP handler method {method.Identifier} must be non-generic and take exactly two parameters.", + method => method.ParameterList + ); }