Testing a Saturn Web App

The first thing I will trying when using a new framework or library is to start writing code in a script file and see what happens in the interactive. Normally, I am just to stupid to understand the guidelines, but by experimenting and finding it out myself by writing code I manage to get my head around the new framework or library I am using.

The same applies when I tried to get a web app running using Saturn. Previously, I used Suave and then Giraffe as primary web server solutions. Saturn is actually, yet another shell around the ASP.NET core framework, actually Giraffe wraps ASP.NET Core and Saturn wraps Giraffe. So, I had some previous experience with similar frameworks. Still, getting a web app up and running handling requests is a daunting task (to me it is).

Requests and Responses

The whole notion of a web app is that there is a client that fires requests at a server, which responds with … responds. In order to keep things simple I want my webserver to have one channel for dynamic requests: api/request, using a POST method to post the actual request. Upon which the server responds with an option response (things can go wrong). I therefore have the following:

namespace Shared

module Request =

    module Configuration =

        type Msg = Get

    module Patient =

        type Msg =
            | Init
            | Clear
            | Get
            | Year of int
            | Month of int
            | Weight of float
            | Height of float

    module AcuteList =
        type Msg = Get of Shared.Models.Patient.Patient

    type Msg =
        | PatientMsg of Patient.Msg
        | AcuteListMsg of AcuteList.Msg
        | ConfigMsg of Configuration.Msg

First of all the code is shared, so the request type can be used by both the client and the server (using the SAFE-stack setup). Thus the client can sent a request the server also understands. Secondly I wrap the actually request in the request type. This enables me to have one type that represents patient requests or configuration requests, etc… The whole thing looks like a:

Request –> Response function

Next the response looks very similar.

namespace Shared

module Response =

    module Configuration =
        type Settings = Setting list
        and Setting =
            { Department : string
              MinAge : int
              MaxAge : int
              MinWeight : float
              MaxWeight : float }

    type Response =
        | Configuration of Configuration.Settings
        | Patient of Shared.Models.Patient.Patient

    open Configuration

    let createSettings dep mina maxa minw maxw =
        {
            Department = dep
            MinAge = mina
            MaxAge = maxa
            MinWeight = minw
            MaxWeight = maxw
        }

Again the code is shared by server and client, so the server can create a response the client understands. And now I want to start playing around with this concept.

Running the code in a script file

The first thing you need to do is to run the code in a script file. In a previous post I described how to load all the required libraries in the script file. The caveat being that sometimes the referenced libraries are in the wrong order. The same applies here, so I had to manually move the following libs down to the end of the load script file:

#r "C:\\Users\\foo\\.nuget\\packages\\giraffe\\3.5.1\\lib\\net461\\Giraffe.dll"
#r "C:\\Users\\foo\\.nuget\\packages\\thoth.json.giraffe\\1.1.0\\lib\\netstandard2.0\\Thoth.Json.Giraffe.dll" 
#r "C:\\Users\\foo\\.nuget\\packages\\saturn\\0.8.0\\lib\\netstandard2.0\\Saturn.dll" 

I also make sure that the path is set to the current source code path so relative paths can be resolved to the correct absolute paths.

Environment.CurrentDirectory <- Path.Combine( __SOURCE_DIRECTORY__, "./../")

My script file resides in a sub folder of the actually server code, hence the “../../”.

I then can create the actual server.

module Server =


    let dataPath = Path.GetFullPath "./../../data/config/genpres.config.json"


    let processRequest (req : Request.Msg) =
        match req with
        | Request.ConfigMsg msg ->
            match msg with
            | Request.Configuration.Get ->
                Path.GetFullPath dataPath
                |> File.ReadAllText
                |> Decode.Auto.unsafeFromString<Shared.Response.Response>
                |> Some
        | Request.PatientMsg msg ->
            match msg with
            | Request.Patient.Init ->
                Shared.Models.Patient.patient
                |> Shared.Response.Patient  
                |> Some
            | _ -> None
        
        | _ -> None


    let tryGetEnv =
        System.Environment.GetEnvironmentVariable
        >> function
        | null
        | "" -> None
        | x -> Some x

    let publicPath = Path.GetFullPath "../Client/public"

    let port =
        "SERVER_PORT"
        |> tryGetEnv
        |> Option.map uint16
        |> Option.defaultValue 8085us

    let getInitCounter() : Task<Counter> = task { return { Value = 42 } }


    let webApp =
        router
            {
            get "/api/init" (fun next ctx -> task { let! counter = getInitCounter()
                                                    return! json counter next ctx }) 
            post "/api/request" (fun next ctx ->
                task {
                    let! resp = task {
                        let! req = ctx.BindJsonAsync<Shared.Request.Msg>()
                        return req |> processRequest }
                    return! json resp next ctx }) }

    let configureSerialization (services : IServiceCollection) =
        services.AddSingleton<Giraffe.Serialization.Json.IJsonSerializer>
            (Thoth.Json.Giraffe.ThothSerializer())

    let app =
        application {
            url ("http://0.0.0.0:" + port.ToString() + "/")
            use_router webApp
            memory_cache
            use_static publicPath
            service_config configureSerialization
            use_gzip
        }

This code handles the retrieval of the request:

            post "/api/request" (fun next ctx ->
                task {
                    let! resp = task {
                        let! req = ctx.BindJsonAsync<Shared.Request.Msg>()
                        return req |> processRequest }
                    return! json resp next ctx }) }

This code handles the processing of the request and the generation of the response:

    let processRequest (req : Request.Msg) =
        match req with
        | Request.ConfigMsg msg ->
            match msg with
            | Request.Configuration.Get ->
                Path.GetFullPath dataPath
                |> File.ReadAllText
                |> Decode.Auto.unsafeFromString<Shared.Response.Response>
                |> Some
        | Request.PatientMsg msg ->
            match msg with
            | Request.Patient.Init ->
                Shared.Models.Patient.patient
                |> Shared.Response.Patient  
                |> Some
            | _ -> None
        
        | _ -> None

Fairly simple isn’t it. But then I want to start playing around with this setup. Therefore, I need to start the server as a separate process and be able to stop the server once I am done. Simply running: run Server.app, as normal won’t work because then the server is running in process and I cannot start ‘talking’ to the server by running code in the FSI.

Starting up and stopping a web server in a separate thread

Luckily, F# has an elegant solution to enable code to run in a separate process called MailboxProcessors. So, you can start the webserver in a MailboxProcessor, or Agent and then run code to use the webserver. The one thing is that if you stop the MailboxProcessor, the webserver still keeps running until you restart the FSI. Therefore, you need to start the webserver with a CancellationToken, like:

module Application =
    open Microsoft.AspNetCore.Hosting

    ///Runs Saturn application with the ability to stop the server
    let start (app: IWebHostBuilder) token =
        app.Build().RunAsync(token) |> ignore

Finally I have to create and be able to start and stop the webserver.

module Test =

    type WebServerMsg = Start | Stop

    let createWebServer () =
        let source = new CancellationTokenSource()

        MailboxProcessor.Start <| fun inbox ->
            let rec loop (source : CancellationTokenSource) =
                async {
                    let! msg = inbox.Receive()
                    match msg with
                    | Start ->
                        printfn "Starting the webserver"
                        Application.start Server.app source.Token
                        return! loop source
                    | Stop ->
                        printfn "Stopping the webserver"
                        source.Cancel ()
                        return ()
                }
            loop source


    let server = createWebServer ()


    type Method = GET | POST

    let fetchUrl method json url =
        let encode json = Encode.Auto.toString(0, json, false)
        let req = WebRequest.Create(Uri(url))

        req.ContentType <- "application/json"
        req.Method <- match method with | GET -> "GET" | POST -> "POST"

        match json with
        | Some json ->

            use streamWriter = new StreamWriter(req.GetRequestStream())
            let s = json |> encode

            streamWriter.Write(s)
            streamWriter.Flush()
            streamWriter.Close()
        | None -> ()
        
        use resp = req.GetResponse()
        
        use stream = resp.GetResponseStream() 
        use reader = new IO.StreamReader(stream)
        try
            let html = reader.ReadToEnd()
            printfn "finished downloading %s" url 
            html 

        with
        | e ->
            printfn "error: %s" e.Message
            ""

Now I can write some code to test my setup:

open Test

Start
|> server.Post

"http://localhost:8085/api/request"
|> fetchUrl POST (Request.Configuration.Get |> Request.ConfigMsg |> Some)
|> Decode.Auto.unsafeFromString<Shared.Response.Response Option>

Stop
|> server.Post

The one thing that is not working (yet) is the ability to restart the server. Once it is stop, you need to reset the FSI and reload again. So, if anybody has a solution? Meanwhile this is a perfect way to play with a webserver.

Leave a Reply

Your email address will not be published.