Tutorial: Using Saon with Giraffe

In this tutorial you will learn how to use Saon together with Giraffe or Saturn.

We start by defining the model for our application and the json and query parsers. You can read more about the json parser and query parser in their respective tutorials.

For now, the important thing to note is that parseContactDetails has signature JsonElement -> ParserResult<Model.ContactDetails> and parseLookup has signature IQueryCollection -> ParserResult<Model.Lookup>.

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
11: 
12: 
13: 
14: 
15: 
16: 
17: 
18: 
19: 
20: 
21: 
22: 
23: 
24: 
25: 
26: 
27: 
28: 
29: 
30: 
31: 
32: 
33: 
34: 
35: 
36: 
37: 
38: 
39: 
40: 
41: 
42: 
43: 
44: 
45: 
module Model =
    type Email = Email of string
    type LookupType = LookupName | LookupEmail
    type Lookup =
        | LookupName of string
        | LookupEmail of Email

    type ContactDetails =
        { Name : string
          Email : Email }


module Dto =
    open Model
    open Saon
    open Saon.Query
    open Saon.Json
    open Saon.Operators

    let parseEmail =
        Validate.isNotEmptyOrWhitespace
        /> Validate.isEmail
        /> Convert.withFunction Email

    let parseContactDetails = jsonObjectParser {
        let! name = Json.property "name" (Json.string /> Validate.isNotEmptyOrWhitespace)
        let! email = Json.property "email" (Json.string /> parseEmail)
        return { Name = name; Email = email }
    }

    let parseLookupType paramName = function
        | "name" -> ParserResult.success LookupType.LookupName
        | "email" -> ParserResult.success LookupType.LookupEmail
        | _ -> ParserResult.validationFail "type" paramName "must be one of 'name', 'email'"

    let parseLookup = queryParser {
        let! lookupType = Query.parameter "type" (Query.string /> parseLookupType)
        match lookupType with
        | LookupType.LookupName ->
            let! value = Query.parameter "value" Query.string
            return LookupName value
        | LookupType.LookupEmail ->
            let! value = Query.parameter "value" (Query.string /> Validate.isEmail)
            return Email value |> LookupEmail
    }

Helper function to parse json payloads and query strings

We now define a couple of helper functions that you can re-use in your application. The functions are similar to Giraffe own bindJson and bindQueryString, the difference is that they take a Saon parser as an additional parameter.

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
11: 
12: 
13: 
14: 
15: 
16: 
17: 
18: 
19: 
20: 
21: 
22: 
23: 
24: 
25: 
26: 
module Helper =
    open Microsoft.AspNetCore.Http.Features
    open Saon
    open System.Text.Json

    let internal badRequest payload : HttpHandler =
        clearResponse >=> setStatusCode 400 >=> json payload

    let internal handleParserResult (result : ParserResult<'T>) (handler : 'T -> HttpHandler) : HttpHandler =
        match result with
        | Success value -> handler value
        | ParsingFailed (field, msg) ->
            badRequest {| Message = msg |}
        | ValidationFailed failMap ->
            badRequest {| Message = "validation failed"; Errors = failMap |}

    let bindJson<'T> (parse : JsonElement -> ParserResult<'T>) (handler : 'T -> HttpHandler) : HttpHandler =
        fun next (ctx : HttpContext) ->
            task {
                let! document = JsonDocument.ParseAsync(ctx.Request.Body)
                return! handleParserResult (parse document.RootElement) handler next ctx
            }

    let bindQuery<'T> (parse : IQueryCollection -> ParserResult<'T>) (handler : 'T -> HttpHandler) : HttpHandler =
        fun next (ctx : HttpContext) ->
            handleParserResult (parse ctx.Request.Query) handler next ctx

Http handling

We can now use the helper functions together with the parsers to define the http handlers.

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
11: 
12: 
13: 
14: 
15: 
16: 
17: 
18: 
19: 
module Handler =
    let createContact (contact : Model.ContactDetails) =
        fun next (ctx : HttpContext) ->
            let logger = ctx.GetLogger()
            logger.LogInformation(sprintf "Create contact %A" contact)
            json {| Message = "ok" |} next ctx

    let lookupContact (filter : Model.Lookup) =
        fun next (ctx : HttpContext) ->
            let logger = ctx.GetLogger()
            logger.LogInformation(sprintf "Lookup contact %A" filter)
            json {| Message = "ok" |} next ctx


let webApp =
    choose [
        POST >=> route "/" >=> Helper.bindJson Dto.parseContactDetails Handler.createContact
        GET >=> route "/" >=> Helper.bindQuery Dto.parseLookup Handler.lookupContact
    ]

Testing it out

We can start by sending valid http requests to the endpoints to see everything is working well on the happy path.

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
11: 
12: 
13: 
14: 
15: 
16: 
POST http://localhost:5000/
Content-Type: application/json

{ "name": "Su", "email": "su@foo.bar" }

=>

HTTP/1.1 200 OK
Date: Fri, 17 Apr 2020 17:02:47 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 16

{
  "message": "ok"
}

And

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
11: 
12: 
13: 
GET http://localhost:5000/?type=email&value=su@foo.bar

=>

HTTP/1.1 200 OK
Date: Fri, 17 Apr 2020 17:03:21 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 29

{
  "message": "lookup by email"
}

If we send the wrong payload to the create endpoint, we will receive a detailed error message

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
11: 
12: 
13: 
14: 
15: 
16: 
17: 
18: 
19: 
20: 
21: 
22: 
23: 
24: 
25: 
POST http://localhost:5000/
Content-Type: application/json

{ "name": "", "email": "su@foo.bar" }

=>

HTTP/1.1 400 Bad Request
Date: Fri, 17 Apr 2020 17:05:15 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 147

{
  "errors": {
    "name": [
      {
        "property": "name",
        "type": "isNotEmptyOrWhitespace",
        "message": "must be not empty or whitespace"
      }
    ]
  },
  "message": "validation failed"
}

Likewise for the lookup endpoint

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
11: 
12: 
13: 
14: 
15: 
16: 
17: 
18: 
19: 
20: 
21: 
22: 
GET http://localhost:5000/?type=email&value=su

=>

HTTP/1.1 400 Bad Request
Date: Fri, 17 Apr 2020 17:05:57 GMT
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 132

{
  "errors": {
    "value": [
      {
        "property": "value",
        "type": "isEmail",
        "message": "must be a valid email address"
      }
    ]
  },
  "message": "validation failed"
}

Evolving APIs

APIs are seldom static and they tend to change over time. F# and Saon make it easy to build API endpoints that are backward compatible.

In this example, we change the ContactDetails type so that users can be contacted either by email or phone. The endpoint will accept two types of payload, the old legacy one:

1: 
{ "name": "Su", "email": "su@foo.bar" }

And a new one:

1: 
{ "name": "Su", "contact": {"type": "email", "email": "su@foo.bar" } }

Or

1: 
{ "name": "Su", "contact": {"type": "phone", "prefix": "+44", "number": "1234567" } }

We start by changing the definition of ContactDetails to the following:

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
type Phone = Phone of string * string

[<RequireQualifiedAccess>]
type Contact =
    | Email of Email
    | Phone of Phone

type ContactDetails =
    { Name : string
      Contact : Contact }

Then we define a parser for the new Contact type, and update the old contact details parser.

 1: 
 2: 
 3: 
 4: 
 5: 
 6: 
 7: 
 8: 
 9: 
10: 
11: 
12: 
13: 
14: 
15: 
16: 
17: 
18: 
19: 
20: 
let parseContact = jsonObjectParser {
    let! typ = Json.property "type" Json.string
    match typ with
    | "email" ->
        let! email = Json.property "email" (Json.string /> parseEmail)
        return Contact.Email email
    | _ ->
        let! prefix = Json.property "prefix" (Json.string /> Validate.hasMinLength 2)
        let! number = Json.property "number" (Json.string /> Validate.hasMinLength 3)
        return Contact.Phone (Phone (prefix, number))
}

let parseContactDetails = jsonObjectParser {
    let! name = Json.property "name" (Json.string /> Validate.isNotEmptyOrWhitespace)
    let! contact =
        [ "email", Json.string /> parseEmail /> Convert.withFunction Contact.Email
          "contact", Json.object parseContact ]
        |> Json.oneOf
    return { Name = name; Contact = contact }
}

Notice we use the function Json.oneOf to parse the contact details. This function takes a list of property names and transformers, then returns the value of the transformers applied to the first property that matches one of the properties. If you need a stricter behaviour, Json.onlyOneOf returns success if only one property matches.

After updating the parser we can update the application logic to handle the new contact type. Notice that from the business logic point of view, we only need to handle the new API and do not have to deal with the old one. When we are ready to drop support for the old payload, we only need to update the parser without changing the business logic.

Multiple items
union case Email.Email: string -> Email

--------------------
type Email = | Email of string
Multiple items
val string : value:'T -> string

--------------------
type string = System.String
type LookupType =
  | LookupName
  | LookupEmail
union case LookupType.LookupName: LookupType
union case LookupType.LookupEmail: LookupType
type Lookup =
  | LookupName of string
  | LookupEmail of Email
union case Lookup.LookupName: string -> Lookup
union case Lookup.LookupEmail: Email -> Lookup
type ContactDetails =
  {Name: string;
   Email: Email;}
ContactDetails.Name: string
Multiple items
ContactDetails.Email: Email

--------------------
type Email = | Email of string
module Model

from Giraffe-tutorial
val parseEmail : obj
val parseContactDetails : obj
val parseLookupType : paramName:'a -> _arg1:string -> 'b
val paramName : 'a
val parseLookup : obj
namespace Microsoft
namespace System
namespace System.Text
val internal badRequest : payload:'a -> obj
val payload : 'a
val internal handleParserResult : result:'a -> handler:('T -> 'b) -> 'c
val result : 'a
val handler : ('T -> 'b)
val bindJson : parse:(obj -> obj) -> handler:('T -> obj) -> next:obj -> ctx:obj -> obj
val parse : (obj -> obj)
val handler : ('T -> obj)
val next : obj
val ctx : obj
val bindQuery : parse:(obj -> obj) -> handler:('T -> obj) -> next:obj -> ctx:obj -> obj
type Handler<'T> =
  delegate of obj * 'T -> unit
val createContact : contact:Model.ContactDetails -> next:'a -> ctx:'b -> 'c
val contact : Model.ContactDetails
val next : 'a
val ctx : 'b
val logger : obj
val sprintf : format:Printf.StringFormat<'T> -> 'T
val lookupContact : filter:Model.Lookup -> next:'a -> ctx:'b -> 'c
val filter : Model.Lookup
val webApp : obj
module Helper

from Giraffe-tutorial
module Dto

from Giraffe-tutorial
Multiple items
module Handler

from Giraffe-tutorial

--------------------
type Handler<'T> =
  delegate of obj * 'T -> unit
Multiple items
union case Phone.Phone: string * string -> Phone

--------------------
type Phone = | Phone of string * string
Multiple items
type RequireQualifiedAccessAttribute =
  inherit Attribute
  new : unit -> RequireQualifiedAccessAttribute

--------------------
new : unit -> RequireQualifiedAccessAttribute
type Contact =
  | Email of obj
  | Phone of Phone
union case Contact.Email: obj -> Contact
Multiple items
union case Contact.Phone: Phone -> Contact

--------------------
type Phone = | Phone of string * string
type ContactDetails =
  {Name: string;
   Contact: Contact;}
Multiple items
ContactDetails.Contact: Contact

--------------------
type Contact =
  | Email of obj
  | Phone of Phone
val parseContact : obj
union case Contact.Phone: Phone -> Contact