diff --git a/src/Http/Wolverine.Http/CodeGen/IReadHttpFrame.cs b/src/Http/Wolverine.Http/CodeGen/IReadHttpFrame.cs index 1a68c302e..bebd2791e 100644 --- a/src/Http/Wolverine.Http/CodeGen/IReadHttpFrame.cs +++ b/src/Http/Wolverine.Http/CodeGen/IReadHttpFrame.cs @@ -3,6 +3,7 @@ using JasperFx.CodeGeneration.Model; using JasperFx.Core.Reflection; using Microsoft.AspNetCore.Http; +using Wolverine.Configuration; namespace Wolverine.Http.CodeGen; @@ -133,7 +134,7 @@ private void writeStringValueFSharp(GeneratedMethod method, ISourceWriter writer // A missing required route value 404s and aborts. F# has no early return, so the rest of // the chain renders inside the else branch. writer.Write($"BLOCK:if isNull {Variable.Usage} then"); - writeStatusAbort(writer); + writeStatusAbort(method, writer); writer.FinishBlock(); writer.Write("BLOCK:else"); WriteNextOrUnit(method, writer); @@ -167,7 +168,7 @@ private void writeParsedValueFSharp(GeneratedMethod method, ISourceWriter writer // Required route value: null or parse-failure 404s + aborts; success binds the variable and // renders the rest of the chain in the success match arm (no F# early return). writer.Write($"BLOCK:if isNull {raw} then"); - writeStatusAbort(writer); + writeStatusAbort(method, writer); writer.FinishBlock(); writer.Write("BLOCK:else"); writer.Write($"BLOCK:match {fsharpTryParse(raw)} with"); @@ -175,7 +176,7 @@ private void writeParsedValueFSharp(GeneratedMethod method, ISourceWriter writer WriteNextOrUnit(method, writer); writer.FinishBlock(); writer.Write("BLOCK:| _ ->"); - writeStatusAbort(writer); + writeStatusAbort(method, writer); writer.FinishBlock(); writer.FinishBlock(); // match writer.FinishBlock(); // else @@ -197,14 +198,14 @@ private void WriteNextOrUnit(GeneratedMethod method, ISourceWriter writer) } else { - writer.Write("()"); + writer.Write(FSharpEmitHelpers.AbortExpression(method)); } } - private static void writeStatusAbort(ISourceWriter writer) + private static void writeStatusAbort(GeneratedMethod method, ISourceWriter writer) { writer.Write($"httpContext.Response.{nameof(HttpResponse.StatusCode)} <- 404"); - writer.Write("()"); + writer.Write(FSharpEmitHelpers.AbortExpression(method)); } // The F# tuple form of the C# out-parameter TryParse (F# auto-tuples the out arg). diff --git a/src/Http/Wolverine.Http/CodeGen/ResultContinuationPolicy.cs b/src/Http/Wolverine.Http/CodeGen/ResultContinuationPolicy.cs index d5ac0d270..4995843d8 100644 --- a/src/Http/Wolverine.Http/CodeGen/ResultContinuationPolicy.cs +++ b/src/Http/Wolverine.Http/CodeGen/ResultContinuationPolicy.cs @@ -73,4 +73,9 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } + + // NOTE: F# emit for MaybeEndWithResultFrame is deferred. The frame is added by ResultContinuationPolicy + // during a full endpoint compile (MapWolverineEndpoints); the no-host ChainFor harness used by the F# + // fixture doesn't apply continuation policies, so it can't exercise this frame yet. It will be done + // with the behavioural (host-based) harness (GH-2969). } \ No newline at end of file diff --git a/src/Testing/Wolverine.Http.FSharpContracts/Endpoints.cs b/src/Testing/Wolverine.Http.FSharpContracts/Endpoints.cs index 6f80209e5..12157eac5 100644 --- a/src/Testing/Wolverine.Http.FSharpContracts/Endpoints.cs +++ b/src/Testing/Wolverine.Http.FSharpContracts/Endpoints.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Http; + namespace Wolverine.Http.FSharpContracts; /// The JSON body bound by . @@ -52,4 +54,13 @@ public string Paged(int page) { return $"page {page}"; } + + // IResult return: a terminal IResult endpoint. The handler's IResult is executed directly as the + // returned Task (ReturnFromLastNode); combined with a route value it exercises the AsyncMode-aware + // abort (a missing route value yields Task.CompletedTask, not unit). + [WolverineGet("/fsharp/result/{id}")] + public IResult GetResult(string id) + { + return string.IsNullOrEmpty(id) ? Results.NotFound() : Results.Ok($"thing {id}"); + } } diff --git a/src/Testing/Wolverine.Http.FSharpFixture/Generated.fs b/src/Testing/Wolverine.Http.FSharpFixture/Generated.fs index 4b35de242..cc4394e2e 100644 --- a/src/Testing/Wolverine.Http.FSharpFixture/Generated.fs +++ b/src/Testing/Wolverine.Http.FSharpFixture/Generated.fs @@ -122,3 +122,20 @@ type GET_fsharp_paged(wolverineHttpOptions: Wolverine.Http.WolverineHttpOptions) do! Wolverine.Http.HttpHandler.WriteString(httpContext, result_of_Paged) } +type GET_fsharp_result_id(wolverineHttpOptions: Wolverine.Http.WolverineHttpOptions) = + inherit Wolverine.Http.HttpHandler(wolverineHttpOptions) + let _wolverineHttpOptions = wolverineHttpOptions + + override this.Handle(httpContext: Microsoft.AspNetCore.Http.HttpContext) : System.Threading.Tasks.Task = + let id = (httpContext.GetRouteValue("id") :?> string) + if isNull id then + httpContext.Response.StatusCode <- 404 + System.Threading.Tasks.Task.CompletedTask + else + let thingEndpoints = Wolverine.Http.FSharpContracts.ThingEndpoints() + + // The actual HTTP request handler execution + let result = thingEndpoints.GetResult(id) + + result.ExecuteAsync(httpContext) + diff --git a/src/Testing/Wolverine.Http.FSharpTests/HttpFSharpCodegenSample.cs b/src/Testing/Wolverine.Http.FSharpTests/HttpFSharpCodegenSample.cs index c468a3fd5..06ba51b74 100644 --- a/src/Testing/Wolverine.Http.FSharpTests/HttpFSharpCodegenSample.cs +++ b/src/Testing/Wolverine.Http.FSharpTests/HttpFSharpCodegenSample.cs @@ -37,7 +37,8 @@ public static string GenerateCode() HttpChain.ChainFor(x => x.GetById(null!), httpGraph), HttpChain.ChainFor(x => x.Search(null!), httpGraph), HttpChain.ChainFor(x => x.GetItems(null!, 0), httpGraph), // typed int route value - HttpChain.ChainFor(x => x.Paged(0), httpGraph) // typed int query value + HttpChain.ChainFor(x => x.Paged(0), httpGraph), // typed int query value + HttpChain.ChainFor(x => x.GetResult(null!), httpGraph) // terminal IResult + route value }; var generatedAssembly = httpGraph.StartAssembly(httpGraph.Rules); diff --git a/src/Wolverine/Configuration/FSharpEmitHelpers.cs b/src/Wolverine/Configuration/FSharpEmitHelpers.cs index 791c7d702..cf4b9e3a9 100644 --- a/src/Wolverine/Configuration/FSharpEmitHelpers.cs +++ b/src/Wolverine/Configuration/FSharpEmitHelpers.cs @@ -1,5 +1,6 @@ using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; namespace Wolverine.Configuration; @@ -22,8 +23,10 @@ internal static class FSharpEmitHelpers public static void WriteAbortGuard(ISourceWriter writer, GeneratedMethod method, string conditionExpression, Frame? next) { + var abort = AbortExpression(method); + writer.Write($"BLOCK:if {conditionExpression} then"); - writer.Write("()"); + writer.Write(abort); writer.FinishBlock(); writer.Write("BLOCK:else"); @@ -33,9 +36,23 @@ public static void WriteAbortGuard(ISourceWriter writer, GeneratedMethod method, } else { - writer.Write("()"); + writer.Write(abort); } writer.FinishBlock(); } + + /// + /// The expression a branch yields when it does NOT continue the chain (abort / no-op). In a + /// task { } body (AsyncMode.AsyncTask) or a synchronous-Task method (AsyncMode.None, where + /// the machinery appends a trailing Task.CompletedTask) that's just (). But when the + /// method body IS a bare trailing Task expression (AsyncMode.ReturnFromLastNode) every branch must + /// itself be a Task, so the no-op branch yields Task.CompletedTask. + /// + public static string AbortExpression(GeneratedMethod method) + { + return method.AsyncMode == AsyncMode.ReturnFromLastNode + ? "System.Threading.Tasks.Task.CompletedTask" + : "()"; + } }