From 7c2616a9d9ff134d21a9374fa8d1404c5827b15b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:18:15 +0000 Subject: [PATCH 1/3] Initial plan From c8e37fcf87021d08da977a8202ff6ab235afff9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:22:31 +0000 Subject: [PATCH 2/3] Add cookie and session helpers for Suave API --- src/FSharpPlus.AspNetCore.Suave/Library.fs | 23 +++++++++- tests/Tests/Tests.fs | 49 ++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/FSharpPlus.AspNetCore.Suave/Library.fs b/src/FSharpPlus.AspNetCore.Suave/Library.fs index 81ed08b..8cb675b 100644 --- a/src/FSharpPlus.AspNetCore.Suave/Library.fs +++ b/src/FSharpPlus.AspNetCore.Suave/Library.fs @@ -90,6 +90,7 @@ module RequestErrors= module Filters= let response (method : string) = OptionT << fun (x : Context) -> async.Return (if (method = x.request.Method) then Some x else None) let hasFormContentType = OptionT << fun (x : Context) -> async.Return (if x.request.HasFormContentType then Some x else None) + let statefulForSession = OptionT << fun (x : Context) -> async.Return (Some x) let GET (x : Http.Context) = response "GET" x let POST (x : Http.Context) = response "POST" x @@ -119,6 +120,27 @@ module Request = | _ -> None module Header= let tryGet key (r:HttpRequest)=match r.Headers.TryGetValue key with | (true,v)->Some v | _-> None + module Cookie= + let tryGet key (r:HttpRequest)= + match r.Cookies.TryGetValue key with + | true, v -> Some v + | _ -> None + +module HttpContext= + let state (ctx:Context) = + try + let session = ctx.request.HttpContext.Session + Some session + with + | :? InvalidOperationException -> None + +module Session= + let tryGet key (session:ISession) = + match session.GetString key with + | null -> None + | value -> Some value + let set key value (session:ISession) = + session.SetString(key, value) @@ -134,4 +156,3 @@ let appRun (app:WebPart) (appBuilder:IApplicationBuilder)= | None -> return! Task.CompletedTask } appRun runApp appBuilder - diff --git a/tests/Tests/Tests.fs b/tests/Tests/Tests.fs index 1987348..e0cf15d 100644 --- a/tests/Tests/Tests.fs +++ b/tests/Tests/Tests.fs @@ -16,6 +16,7 @@ open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Hosting open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.TestHost +open Microsoft.Extensions.DependencyInjection open Expecto @@ -124,3 +125,51 @@ module ``integration test using test server`` = Expect.equal (parseJson noteJson) (Ok {id=NoteId 1;text="my next text"}) "Expected note json" }) ] + + [] + let ``session state uses cookie`` = + testCase "counter is incremented across requests with same cookie" <| fun _ -> waitFor(task { + let sessionWebPart = + Filters.path "/session" + >=> Filters.statefulForSession + >=> (fun ctx -> + match FSharpPlus.AspNetCore.Suave.HttpContext.state ctx with + | Some store -> + let current = + store + |> FSharpPlus.AspNetCore.Suave.Session.tryGet "counter" + |> Option.bind (fun s -> match Int32.TryParse s with | true, n -> Some n | _ -> None) + |> Option.defaultValue 0 + store |> FSharpPlus.AspNetCore.Suave.Session.set "counter" (string (current + 1)) + Successful.OK (sprintf "Hello %d time(s)" (current + 1)) ctx + | None -> + Successful.OK "No session available" ctx) + + let builder = + WebHostBuilder() + .ConfigureServices(fun services -> + services.AddDistributedMemoryCache() |> ignore + services.AddSession() |> ignore) + .Configure(fun app -> + app.UseSession() |> ignore + Suave.appRun sessionWebPart app |> ignore) + + use testServer = new TestServer(builder) + use client = testServer.CreateClient() + + let! first = client.GetAsync("http://localhost/session") + let! firstContent = first.Content.ReadAsStringAsync() + let cookieHeader = + first.Headers.GetValues("Set-Cookie") + |> Seq.map (fun cookie -> cookie.Split(';').[0]) + |> String.concat "; " + + let request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/session") + request.Headers.Add("Cookie", cookieHeader) + + let! second = client.SendAsync(request) + let! secondContent = second.Content.ReadAsStringAsync() + + Expect.equal firstContent "Hello 1 time(s)" "Expected first session response" + Expect.equal secondContent "Hello 2 time(s)" "Expected second session response" + }) From 39a8669bf05f15b0d3a5bd0a33ed0a9b055c29b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:25:21 +0000 Subject: [PATCH 3/3] Implement cookie and session support APIs --- src/FSharpPlus.AspNetCore.Suave/Library.fs | 15 ++++++++------- tests/Tests/Tests.fs | 8 ++++++-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/FSharpPlus.AspNetCore.Suave/Library.fs b/src/FSharpPlus.AspNetCore.Suave/Library.fs index 8cb675b..d73ce88 100644 --- a/src/FSharpPlus.AspNetCore.Suave/Library.fs +++ b/src/FSharpPlus.AspNetCore.Suave/Library.fs @@ -9,6 +9,7 @@ open System.Text open System open System.IO open System.Text.RegularExpressions +open Microsoft.AspNetCore.Http.Features // setup something that reminds us of what Suave can work with // this is an overly simplified model of Suave in order to show how OptionT can be used @@ -87,10 +88,15 @@ module RequestErrors= let FORBIDDEN s = setStatusAndContent (int HttpStatusCode.Forbidden) s let NOT_FOUND s = setStatusAndContent (int HttpStatusCode.NotFound) s let UNAUTHORIZED s = setStatusAndContent (int HttpStatusCode.Unauthorized) s +let private tryGetSession (ctx:Context) = + match ctx.request.HttpContext.Features.Get() with + | null -> None + | feature when isNull feature.Session -> None + | feature -> Some feature.Session module Filters= let response (method : string) = OptionT << fun (x : Context) -> async.Return (if (method = x.request.Method) then Some x else None) let hasFormContentType = OptionT << fun (x : Context) -> async.Return (if x.request.HasFormContentType then Some x else None) - let statefulForSession = OptionT << fun (x : Context) -> async.Return (Some x) + let statefulForSession = OptionT << fun (x : Context) -> async.Return (if tryGetSession x |> Option.isSome then Some x else None) let GET (x : Http.Context) = response "GET" x let POST (x : Http.Context) = response "POST" x @@ -127,12 +133,7 @@ module Request = | _ -> None module HttpContext= - let state (ctx:Context) = - try - let session = ctx.request.HttpContext.Session - Some session - with - | :? InvalidOperationException -> None + let state (ctx:Context) = tryGetSession ctx module Session= let tryGet key (session:ISession) = diff --git a/tests/Tests/Tests.fs b/tests/Tests/Tests.fs index e0cf15d..f4a0fe9 100644 --- a/tests/Tests/Tests.fs +++ b/tests/Tests/Tests.fs @@ -25,6 +25,10 @@ open FSharpPlus.AspNetCore.Suave open Notes module ``integration test using test server`` = + let tryParseInt (s:string) = + match Int32.TryParse s with + | true, n -> Some n + | _ -> None module TestServer= let fakeDb() = let withUserId userId = (=) userId << fst @@ -138,7 +142,7 @@ module ``integration test using test server`` = let current = store |> FSharpPlus.AspNetCore.Suave.Session.tryGet "counter" - |> Option.bind (fun s -> match Int32.TryParse s with | true, n -> Some n | _ -> None) + |> Option.bind tryParseInt |> Option.defaultValue 0 store |> FSharpPlus.AspNetCore.Suave.Session.set "counter" (string (current + 1)) Successful.OK (sprintf "Hello %d time(s)" (current + 1)) ctx @@ -161,7 +165,7 @@ module ``integration test using test server`` = let! firstContent = first.Content.ReadAsStringAsync() let cookieHeader = first.Headers.GetValues("Set-Cookie") - |> Seq.map (fun cookie -> cookie.Split(';').[0]) + |> Seq.choose (fun cookie -> cookie.Split(';') |> Array.tryHead) |> String.concat "; " let request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/session")