Created
August 26, 2021 16:00
-
-
Save thinkbeforecoding/6ed8614f21517665937ddd4ed363be81 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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