Skip to content

Instantly share code, notes, and snippets.

@thinkbeforecoding
Created August 26, 2021 16:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thinkbeforecoding/6ed8614f21517665937ddd4ed363be81 to your computer and use it in GitHub Desktop.
Save thinkbeforecoding/6ed8614f21517665937ddd4ed363be81 to your computer and use it in GitHub Desktop.
open System
type LicencePlate = LicencePlate of string
module LicencePlate =
open System.Text.RegularExpressions
let plateEx = Regex @"^[0-9]{3}-[0-9]{3}$"
let parse input =
if plateEx.IsMatch(input) then
LicencePlate input
else
failwith "Invalide licence plate"
type Period = { Start: DateTime; End: DateTime}
module Period =
let contains date period =
date >= period.Start && date < period.End
type Product =
| EndOfDay
| EndOfWeek
module Product =
let period product (date: DateTime) =
match product with
| EndOfDay -> { Start = date; End = date.Date.AddDays(1.) }
| EndOfWeek -> { Start = date; End = date.Date.AddDays(7.) }
// this is a pure version of Vehicle decider with no framework dependency
module Vehicle =
type Event =
| Booked of {| Period: Period |}
| Unbooked of {| Period: Period |}
| InspectionFailed of {| When: DateTime |}
type Command =
| Book of {| Start: DateTime; Product: Product |}
| Unbook of {| Start: DateTime; Product: Product |}
| Inspect of {| When: DateTime |}
type State = Period list
let initialState : State = []
let decide cmd state =
match cmd with
| Book cmd ->
let period = Product.period cmd.Product cmd.Start
if List.contains period state then
[]
else
[ Booked {| Period = period |}]
| Unbook cmd ->
let period = Product.period cmd.Product cmd.Start
if List.contains period state then
[ Unbooked {| Period = period |}]
else
[]
| Inspect cmd ->
if state |> List.exists (Period.contains cmd.When) then
[ ]
else
[ InspectionFailed cmd ]
let evolve state event =
match event with
| Booked booking -> booking.Period :: state
| Unbooked booking -> state |> List.filter (fun p -> p <> booking.Period)
| _ -> state
module App =
open Vehicle
// here we load and fold all events on each call
let book load append =
fun (plate: LicencePlate) product ->
load plate
|> List.fold evolve initialState
|> decide (Book {| Start = DateTime.Now; Product = product|})
|> append plate
let unbook load append =
fun (plate: LicencePlate) start product ->
load plate
|> List.fold evolve initialState
|> decide (Unbook {| Start = start; Product = product|})
|> append plate
let inspect load append =
fun (plate: LicencePlate) ->
load plate
|> List.fold evolve initialState
|> decide (Inspect {| When = DateTime.Now|})
|> append plate
// serialization helpers to save in a 1 event per line file
module Serialization =
open Vehicle
module Period =
let serialize (p: Period) =
p.Start.ToString("o")+"=>"+p.End.ToString("o")
let deserialize (input: ReadOnlySpan<Char>) =
let index = input.IndexOf("=>".AsSpan())
if index < 0 then
failwith "Invalid period"
else
{ Start = DateTime.Parse(input.Slice(0,index)); End = DateTime.Parse(input.Slice(index+2)) }
module Product =
let deserialize input =
match input with
| "EndOfDay" -> EndOfDay
| "EndOfWeek" -> EndOfWeek
| _ -> failwith "Unknown product"
let serialize = function
| Booked e -> $"""Booked:{Period.serialize e.Period}"""
| Unbooked e -> $"""Unbooked:{Period.serialize e.Period}"""
| InspectionFailed e -> $"""InspectionFailed:{e.When.ToString("o")}"""
let deserialize (input: string) =
let span = input.AsSpan()
let index = span.IndexOf(':')
if index < 0 then
[]
else
let eventType = span.Slice(0,index)
if eventType.Equals("Booked".AsSpan(), StringComparison.Ordinal) then
[ Booked {| Period = Period.deserialize (span.Slice(index+1)) |}]
elif eventType.Equals("Unbooked".AsSpan(), StringComparison.Ordinal) then
[ Unbooked {| Period = Period.deserialize (span.Slice(index+1)) |}]
elif eventType.Equals("InspectionFailed".AsSpan(), StringComparison.Ordinal) then
[ InspectionFailed {| When = DateTime.Parse(span.Slice(index+1)) |}]
else
[]
// load from/save to file, 1 event/line
module Infra =
open System.IO
let filePath (LicencePlate plate) = __SOURCE_DIRECTORY__ + "/" + plate + ".txt"
let loadEvents file =
if File.Exists file then
File.ReadAllLines file
|> Seq.collect Serialization.deserialize
|> Seq.toList
else
[]
let appendEvents file events =
if not (List.isEmpty events) then
let lines =
events
|> List.map Serialization.serialize
File.AppendAllLines(file, lines)
// use suave to expose as a Web API
#r "nuget: Suave"
open Suave
open Suave.Web
open Suave.Operators
open Suave.Filters
open System.Runtime.Serialization
type [<DataContract>]BookDto = { [<field: DataMember(Name = "product")>] Product: string}
type [<DataContract>]UnbookDto = { [<field: DataMember(Name="start")>] Start: DateTime; [<field:DataMember(Name="product")>] Product: string}
let service =
let load plate =
let path = Infra.filePath plate
Infra.loadEvents (path)
let book = App.book (Infra.filePath >> Infra.loadEvents) (Infra.filePath >> Infra.appendEvents)
let unbook = App.unbook (Infra.filePath >> Infra.loadEvents) (Infra.filePath >> Infra.appendEvents)
let inspect = App.inspect (Infra.filePath >> Infra.loadEvents) (Infra.filePath >> Infra.appendEvents)
POST >=>
choose [
pathScan "/book/%s" (fun plate ctx ->
let plate = LicencePlate.parse plate
let payload = Json.fromJson<BookDto> ctx.request.rawForm
let product = Serialization.Product.deserialize payload.Product
book plate product
Successful.no_content ctx)
pathScan "/unbook/%s" (fun plate ctx ->
let plate = LicencePlate.parse plate
let payload = Json.fromJson<UnbookDto> ctx.request.rawForm
unbook plate payload.Start (Serialization.Product.deserialize payload.Product)
Successful.no_content ctx)
pathScan "/inspect/%s" (fun plate ctx ->
let plate = LicencePlate.parse plate
inspect plate
Successful.no_content ctx)
]
startWebServer defaultConfig service
// test it with curl
// curl http://localhost:8080/book/123-456 -X POST --data-ascii '{ \"product\": \"EndOfDay\" }'
// curl http://localhost:8080/unbook/123-456 -X POST --data-ascii '{ \"start\": \"..\", \"product\": \"EndOfDay\" }'
// curl http://localhsot:8080/inspect/123-456 -X POST
// You should find a 123-456 file next to the script file containing the events
module Tests =
open Vehicle
// define a dsl for tests:
// events => command =! expected
// reads as "given events, when command, expect expected events"
let (=>) events cmd =
events
|> List.fold evolve initialState
|> Vehicle.decide cmd
let (=!) actual expected =
if actual = expected then
printfn "✔"
else
printfn $"❌: {actual} <> {expected}"
// when booking, it is booked
[]
=> Book {| Start = DateTime(2021,09,01, 11, 00, 00); Product = EndOfDay |}
=! [ Booked {| Period = {Start = DateTime(2021,09,01, 11, 00, 00); End = DateTime(2021,09,02)} |} ]
// When booking twice, nothing happen
[ Booked {| Period = {Start = DateTime(2021,09,01, 11, 00, 00); End = DateTime(2021,09,02)} |} ]
=> Book {| Start = DateTime(2021,09,01, 11, 00, 00); Product = EndOfDay |}
=! []
// When unbooking, existing period is unbooked
[ Booked {| Period = {Start = DateTime(2021,09,01, 11, 00, 00); End = DateTime(2021,09,02)} |} ]
=> Unbook {| Start = DateTime(2021,09,01, 11, 00, 00); Product = EndOfDay |}
=! [ Unbooked {| Period = {Start = DateTime(2021,09,01, 11, 00, 00); End = DateTime(2021,09,02)} |} ]
// When unbooking, unknown period does nothing
[ Booked {| Period = {Start = DateTime(2021,09,01, 11, 00, 00); End = DateTime(2021,09,02)} |} ]
=> Unbook {| Start = DateTime(2021,09,01, 11, 00, 00); Product = EndOfWeek |}
=! [ ]
// inspecting booked period does nothing
[ Booked {| Period = {Start = DateTime(2021,09,01, 11, 00, 00); End = DateTime(2021,09,02)} |} ]
=> Inspect {| When = DateTime(2021,09,01, 23, 59, 59) |}
=! []
// inspecting outside of booked period is reported
[ Booked {| Period = {Start = DateTime(2021,09,01, 11, 00, 00); End = DateTime(2021,09,02)} |} ]
=> Inspect {| When = DateTime(2021,09,02, 00, 00, 00) |}
=! [ InspectionFailed {| When = DateTime(2021,09,02, 00, 00, 00)|} ]
// inspecting when nothing is booked is reported
[]
=> Inspect {| When = DateTime(2021,09,02, 00, 00, 00) |}
=! [ InspectionFailed {| When = DateTime(2021,09,02, 00, 00, 00)|} ]
// inspecting unbooked period is reported
[ Booked {| Period = {Start = DateTime(2021,09,01, 11, 00, 00); End = DateTime(2021,09,02)} |}
Unbooked {| Period = {Start = DateTime(2021,09,01, 11, 00, 00); End = DateTime(2021,09,02)} |} ]
=> Inspect {| When = DateTime(2021,09,01, 23, 59, 59) |}
=! [ InspectionFailed {| When = DateTime(2021,09,01, 23, 59, 59) |} ]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment