In one of the fsharp project that I was part of in early this year, we encountered an interesting scenario where we need to do serialisation of a fsharp record type from the query string (and multi-part form) in Suave, and the out of the box model binding support didn't suit our requirements.
So, we rolled out our own, and the solution came from a library which was not intended to solve this problem. In this blog post, I will be sharing what the problem was and how we solved it. The solution that we came up with is not limited to Suave alone, and it can be used in Saturn and Giraffe as well.
This blog post is a part of fsharp advent calendar 2018.
The Problem Statement
Let's assume that we have the following fsharp types to represent the filter criteria of an e-commerce portal that sells books.
type DealsCategory =
| AllDeals
| AllEBooks
| ActionAndAdventure
| Media
| Fiction
type Language =
| English
| Hindi
| Tamil
type Rating =
| Five
| FourAndAbove
| ThreeAndAbove
type SearchFilter = {
Languages : Language list
Rating : Rating option
}
type Search = {
Category : DealsCategory
Filter : SearchFilter
}
The record type Search
represents the data that we'll be receiving from the front-end as a query string.
curl 'http://localhost:8080/books?category=Fiction&rating=Five&languages=Tamil&languages=English'
In Suave, model binding support is provided using the Suave.Experimental package and it currently supports only binding form values as mentioned in this blog post. However, the form binding logic in this package can be extended by replacing req.formData
with req.query
in this file (you need to copy and paste the code in your project!). A significant limitation was it supports record types with only String
, Decimal
, DateTime
, MailAddress
types for model binding.
The model binding in the Saturn framework (using Giraffe) doesn't have these limitations, and it also supports Discriminated Union Types that has cases alone. Unfortunately, it doesn't support nested records out of the box. It is one of the critical requirement for us as many of our view model's fields are record types.
Let's find a solution to this!
Model Binding
The model binding logic that we wanted to develop should conceptually work like this
It takes three input,
- The record type that it wants to bind
- A value store (query string or form field) to get the values for the fields of the given record type.
- A field name canoncializer to fix the field name transformations like PascalCasing to CamelCasing (
Languages
tolanguages
)
Then it iterates all the fields in the record type, and for each field name, it calls the field name canoncializer to get the corresponding field name in the value store and finally get the value of the given field name from the value store.
If the above operation is successful for all the fields, it should return the value of the given record type else return an appropriate error.
After whiteboarding this stuff out, it struck that I was doing a similar logic in the FsConfig (an F# library for reading configuration data from environment variables and AppSettings) and I can use it use it here!
In FsConfig, the model binding logic reads the value from the environment variables or the application settings file. Here we need to read from query strings or form fields!
Suave Solution Using FsConfig
The parse
function in the FsConfig library has the following function signature
IConfigReader -> FieldNameCanonicalizer -> string -> 'T
The parse
function takes three parameters
[1] The IConfigReader
interface represents the value store in the above diagram.
type IConfigReader =
// given a key, return its value if it exists or none
abstract member GetValue : string -> string option
[2] The FieldNameCanonicalizer
is a function.
type Prefix = Prefix of string
type FieldNameCanonicalizer = Prefix -> string -> string
The first parameter represents the prefix which will be either empty or the field name if the corresponding field is a record type. The second parameter is the actual field name.
[3] The third parameter is the custom prefix that you may want to prefix for all the fields of the parent record. (Typical environment variables uses some prefix for namespacing like MYAPP_PORT
)
To make it work for Suave, we need to provide appropriate values for these parameters.
The first parameter is IConfigReader
.
// HttpRequest -> Map<string, string>
let queryStringsMap (request : HttpRequest) =
request.query
|> List.groupBy fst
|> List.map (fun (x, keyVals) ->
(x, keyVals
|> List.map snd
|> List.choose id
|> String.concat ","))
|> Map.ofList
// IConfigReader
type HttpQueryStringsProvider(request : HttpRequest) =
// value store for query string
let queryStringsMap = queryStringsMap request
interface IConfigReader with
// retrieving the value from the query string
member __.GetValue name =
Map.tryFind name queryStringsMap
The second parameter is FieldNameCanonicalizer
.
// PascalCase to camelCase
let private camelCaseCanonicalizer _ (name : string) =
name
|> String.mapi (fun i c ->
if (i = 0) then Char.ToLowerInvariant c else c)
We are ignoring the prefix here as we are using are not going to use it.
The final parameter is custom prefix which is a blank string in our case as we are not using any custom prefix.
With these things in place, we can create a new function called bindQueryStrings
which does the model binding using the FsConfig's parse
function.
// HttpRequest -> Result<'T, string>
let bindQueryStrings<'T> (request : HttpRequest) =
let queryStringsProvider = new HttpQueryStringsProvider(request)
parse<'T> queryStringsProvider camelCaseCanonicalizer ""
|> Result.mapError (fun e -> e.ToString())
To see it in action, wire it up in a webpart
let getBooks ctx = async {
let search = bindQueryStrings<Search> ctx.request
// just printing it for brevity
printfn "%A" search
return! Successful.OK "Todo" ctx
}
let app =
path "/books" >=> getBooks
[<EntryPoint>]
let main argv =
startWebServer defaultConfig app
0
Saturn Solution Using FsConfig
The solution in Saturn using FsConfig will be very similar. We need to use HTTP models specific to Saturn to retrieve the query string values.
type QueryStringReader(ctx : HttpContext) =
interface IConfigReader with
member __.GetValue name =
printfn "--> %s" name
match ctx.Request.Query.TryGetValue name with
| true, x -> Some (x.ToString())
| _ -> None
let bindQueryStrings<'T> (ctx : HttpContext) =
let reader = new QueryStringReader(ctx)
parse<'T> reader camelCaseCanonicalizer ""
And then use it like below
let getBooks (ctx : HttpContext) = task {
let search = bindQueryString<Search> ctx
// just printing it for brevity
printfn "%A" search
return! Controller.text ctx "TODO"
}
let bookController = controller {
index getBooks
}
let apiRouter = router {
forward "/books" bookController
}
let app = application {
use_router apiRouter
url "http://0.0.0.0:8080"
}
[<EntryPoint>]
let main _ =
run app
Summary
Tomas Petricek once wrote an excellent blog post on library vs frameworks where he encourages us to focus on using libraries which can be plugged to any code to solve the problems. The effort that we did here remind me of this blog post and it was amazing that it fits well with both Suave and Saturn.
This model binding logic can be extended to bind the data from anywhere even from a database like Slapper.AutoMapper. It has some loose ends. If I get some time to fix those quirks, I am planning to release this as a separate library.
You can find the source code associated with this blog post in my GitHub repository.