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
}
|
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
|
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
]
|
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"
}
|
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