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.