├── .gitignore
├── Acceptance
├── Acceptance.fsproj
├── Program.fs
├── WithFlexibleCompositionRoot
│ ├── TestFlexibleCompositionRoot.fs
│ └── Tests2.fs
└── WithInflexibleCompositionRoot
│ ├── TestInflexibleCompositionRoot.fs
│ └── Tests1.fs
├── Api
├── Api.fsproj
├── Dtos.fs
├── FlexibleCompositionRoot
│ ├── FlexibleCompositionRoot.fs
│ ├── Leaves
│ │ └── StockItemWorkflowsDependencies.fs
│ └── Trunk.fs
├── HttpHandler.fs
├── IdGenerator.fs
├── InflexibleCompositionRoot.fs
├── Program.fs
├── Settings.fs
├── Views.fs
├── WebRoot
│ └── main.css
└── appsettings.json
├── FsharpComposition.sln
├── Intragration
├── Intragration.fsproj
├── Program.fs
└── StockItemDao.fs
├── README.md
├── Stock.Application
├── Queries
│ └── StockItemById.fs
├── Stock.Application.fsproj
└── StockItemWorkflows.fs
├── Stock.PostgresDao
├── DapperFSharp.fs
├── Stock.PostgresDao.fsproj
├── StockItemDao.fs
└── StockItemQueryDao.fs
├── Stock
├── Stock.fsproj
└── StockItem.fs
├── Toolbox
├── HttpContext.fs
├── Toolbox.fs
└── Toolbox.fsproj
├── Unit
├── Program.fs
├── StockItem.fs
└── Unit.fsproj
├── database.sql
└── docker-compose.yml
/.gitignore:
--------------------------------------------------------------------------------
1 | #KaDiff3
2 | *.orig
3 |
4 | # User-specific files
5 | *.suo
6 | *.user
7 | *.userosscache
8 | *.sln.docstates
9 | *.DotSettings.user
10 |
11 | # User-specific files (MonoDevelop/Xamarin Studio)
12 | *.userprefs
13 |
14 | # Build results
15 | [Dd]ebug/
16 | [Dd]ebugPublic/
17 | [Rr]elease/
18 | [Rr]eleases/
19 | x64/
20 | x86/
21 | bld/
22 | [Bb]in/
23 | [Oo]bj/
24 | [Ll]og/
25 |
26 | # Visual Studio 2015 cache/options directory
27 | .vs/
28 |
29 | # MSTest test Results
30 | [Tt]est[Rr]esult*/
31 | [Bb]uild[Ll]og.*
32 |
33 | # NUNIT
34 | *.VisualState.xml
35 | TestResult.xml
36 |
37 | # Build Results of an ATL Project
38 | [Dd]ebugPS/
39 | [Rr]eleasePS/
40 | dlldata.c
41 |
42 | # DNX
43 | project.lock.json
44 | project.fragment.lock.json
45 | artifacts/
46 |
47 | *_i.c
48 | *_p.c
49 | *_i.h
50 | *.ilk
51 | *.meta
52 | *.obj
53 | *.pch
54 | *.pdb
55 | *.pgc
56 | *.pgd
57 | *.rsp
58 | *.sbr
59 | *.tlb
60 | *.tli
61 | *.tlh
62 | *.tmp
63 | *.tmp_proj
64 | *.log
65 | *.vspscc
66 | *.vssscc
67 | .builds
68 | *.pidb
69 | *.svclog
70 | *.scc
71 |
72 | # Chutzpah Test files
73 | _Chutzpah*
74 |
75 | # Visual Studio profiler
76 | *.psess
77 | *.vsp
78 | *.vspx
79 | *.sap
80 |
81 | # ReSharper is a .NET coding add-in
82 | _ReSharper*/
83 | *.[Rr]e[Ss]harper
84 | *.DotSettings.user
85 |
86 | # DotCover is a Code Coverage Tool
87 | *.dotCover
88 |
89 | # NCrunch
90 | _NCrunch_*
91 | .*crunch*.local.xml
92 | nCrunchTemp_*
93 | *.ncrunchsolution
94 |
95 | # Click-Once directory
96 | publish/
97 |
98 | # NuGet Packages
99 | *.nupkg
100 | # The packages folder can be ignored because of Package Restore
101 | **/packages/*
102 | # except build/, which is used as an MSBuild target.
103 | !**/packages/build/
104 | # Uncomment if necessary however generally it will be regenerated when needed
105 | #!**/packages/repositories.config
106 | # NuGet v3's project.json files produces more ignoreable files
107 | *.nuget.props
108 | *.nuget.targets
109 |
110 | # Paket dependency manager
111 | .paket/paket.exe
112 | paket-files/
113 |
114 | # FAKE - F# Make
115 | .fake/
116 |
117 | # JetBrains Rider
118 | .idea/
119 | *.sln.iml
120 |
--------------------------------------------------------------------------------
/Acceptance/Acceptance.fsproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net5.0
4 | false
5 | false
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | runtime; build; native; contentfiles; analyzers; buildtransitive
20 | all
21 |
22 |
23 | runtime; build; native; contentfiles; analyzers; buildtransitive
24 | all
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/Acceptance/Program.fs:
--------------------------------------------------------------------------------
1 | module Program = let [] main _ = 0
2 |
--------------------------------------------------------------------------------
/Acceptance/WithFlexibleCompositionRoot/TestFlexibleCompositionRoot.fs:
--------------------------------------------------------------------------------
1 | module TestFlexibleCompositionRoot
2 |
3 | open System
4 | open Api.FlexibleCompositionRoot
5 | open FlexibleCompositionRoot
6 | open Settings
7 | let testSettings: Settings =
8 | // We can test with database but we don't have to.
9 | { SqlConnectionString = "Host=localhost;User Id=postgres;Password=Secret!Passw0rd;Database=stock;Port=5432"
10 | IdGeneratorSettings =
11 | { GeneratorId = 555
12 | Epoch = DateTimeOffset.Parse "2020-10-01 12:30:00"
13 | TimestampBits = byte 41
14 | GeneratorIdBits = byte 10
15 | SequenceBits = byte 12 } }
16 |
17 | let composeRoot tree = compose tree
18 | let testTrunk = Trunk.compose testSettings
19 |
20 | let ``with StockItem -> ReadBy`` substitute (trunk: Trunk.Trunk) =
21 | { trunk with StockItemWorkflowDependencies = { trunk.StockItemWorkflowDependencies with ReadBy = substitute } }
22 |
23 | let ``with StockItem -> Update`` substitute (trunk: Trunk.Trunk) =
24 | { trunk with StockItemWorkflowDependencies = { trunk.StockItemWorkflowDependencies with Update = substitute } }
25 |
26 | let ``with StockItem -> Insert`` substitute (trunk: Trunk.Trunk) =
27 | { trunk with StockItemWorkflowDependencies = { trunk.StockItemWorkflowDependencies with Insert = substitute } }
28 |
29 | let ``with Query -> StockItemById`` substitute (trunk: Trunk.Trunk) =
30 | { trunk with QueryStockItemBy = substitute }
--------------------------------------------------------------------------------
/Acceptance/WithFlexibleCompositionRoot/Tests2.fs:
--------------------------------------------------------------------------------
1 | module Tests2
2 |
3 | open System
4 | open Api
5 | open Dtos
6 | open Microsoft.AspNetCore.Http
7 | open Stock.StockItem
8 | open Xunit
9 | open HttpContext
10 | open FSharp.Control.Tasks.V2
11 | open FsUnit.Xunit
12 | open TestFlexibleCompositionRoot
13 |
14 | []
15 | let ``GIVEN id of not existing stock item WHEN QueryStockItemBy THEN none is returned and mapped into 404 not found`` () =
16 | let httpContext = buildMockHttpContext ()
17 | // This is trivial example - We could as well build the QueryStockItemBy by ourselves and pass it to the handler.
18 | // To make it easy to understand more complex cases, let me do it by the "Flexible CompositionRoot" way.
19 | let root = testTrunk
20 | |> ``with Query -> StockItemById`` (fun _ -> async { return None })
21 | |> composeRoot
22 | let http =
23 | task {
24 | let! ctxAfterHandler = HttpHandler.queryStockItemHandler root.QueryStockItemBy (int64 5) next httpContext
25 | return ctxAfterHandler
26 | } |> Async.AwaitTask |> Async.RunSynchronously |> Option.get
27 | http.Response.StatusCode |> should equal StatusCodes.Status404NotFound
28 |
29 | []
30 | let ``GIVEN stock item was passed into request WHEN CreateStockItem THEN new stock item is created and location is returned which can be used to fetch created stock item`` () =
31 | // Arrange
32 | let (name, amount) = (Guid.NewGuid().ToString(), Random().Next(1, 15))
33 | let httpContext = buildMockHttpContext ()
34 | |> writeObjectToBody {Name = name; Amount = amount}
35 | // Again we can compose the function by ourself, but using our root we can test both - the function and the
36 | // dependencies composition (as in this way the dependencies in our tests are composed in the same way.
37 | let mutable createdStockItem: StockItem option = None
38 | let root = testTrunk
39 | |> ``with StockItem -> Insert`` (fun stockItem -> async { createdStockItem <- Some stockItem; return () })
40 | |> ``with Query -> StockItemById`` (fun _ ->
41 | async {
42 | let stockItem = createdStockItem.Value
43 | let (StockItemId id) = stockItem.Id
44 | return ( Some {
45 | Id = id
46 | AvailableAmount = stockItem.AvailableAmount
47 | Name = stockItem.Name
48 | }
49 | : Queries.StockItemById.Result option)
50 | })
51 | |> composeRoot
52 | // Act
53 | let http =
54 | task {
55 | let! ctxAfterHandler = HttpHandler.createStockItemHandler root.CreateStockItem root.GenerateId next httpContext
56 | return ctxAfterHandler
57 | } |> Async.AwaitTask |> Async.RunSynchronously |> Option.get
58 | // Assert
59 | http.Response.StatusCode |> should equal StatusCodes.Status201Created
60 | let createdId = http.Response.Headers.["Location"].ToString().[11..] |> Int64.Parse
61 | let httpContext4Query = buildMockHttpContext ()
62 | let httpAfterQuery =
63 | task {
64 | let! ctxAfterQuery = HttpHandler.queryStockItemHandler root.QueryStockItemBy createdId next httpContext4Query
65 | return ctxAfterQuery
66 | } |> Async.AwaitTask |> Async.RunSynchronously |> Option.get
67 | let createdStockItem = httpAfterQuery.Response |> deserializeResponse
68 | createdStockItem.Id |> should equal createdId
69 | createdStockItem.Name |> should equal name
70 | createdStockItem.AvailableAmount |> should equal amount
71 |
72 | // Now You! Write Test for Update in both approaches.
--------------------------------------------------------------------------------
/Acceptance/WithInflexibleCompositionRoot/TestInflexibleCompositionRoot.fs:
--------------------------------------------------------------------------------
1 | module TestInflexibleCompositionRoot
2 |
3 | open System
4 | open InflexibleCompositionRoot
5 | open Settings
6 | let testSettings: Settings =
7 | // We are forced to test against database
8 | { SqlConnectionString = "Host=localhost;User Id=postgres;Password=Secret!Passw0rd;Database=stock;Port=5432"
9 | IdGeneratorSettings =
10 | { GeneratorId = 555
11 | Epoch = DateTimeOffset.Parse "2020-10-01 12:30:00"
12 | TimestampBits = byte 41
13 | GeneratorIdBits = byte 10
14 | SequenceBits = byte 12 } }
15 |
16 | let testRoot = compose testSettings
--------------------------------------------------------------------------------
/Acceptance/WithInflexibleCompositionRoot/Tests1.fs:
--------------------------------------------------------------------------------
1 | module Tests1
2 |
3 | open System
4 | open Api
5 | open Dtos
6 | open Microsoft.AspNetCore.Http
7 | open Xunit
8 | open HttpContext
9 | open FSharp.Control.Tasks.V2
10 | open FsUnit.Xunit
11 | open TestInflexibleCompositionRoot
12 |
13 | []
14 | let ``GIVEN id of not existing stock item WHEN QueryStockItemBy THEN none is returned and mapped into 404 not found`` () =
15 | let httpContext = buildMockHttpContext ()
16 | let http =
17 | task {
18 | let! ctxAfterHandler = HttpHandler.queryStockItemHandler testRoot.QueryStockItemBy (int64 5) next httpContext
19 | return ctxAfterHandler
20 | } |> Async.AwaitTask |> Async.RunSynchronously |> Option.get
21 | http.Response.StatusCode |> should equal StatusCodes.Status404NotFound
22 |
23 | []
24 | let ``GIVEN stock item was passed into request WHEN CreateStockItem THEN new stockitem is created and location is returned which can be used to fetch created stockitem`` () =
25 | // Arrange
26 | let (name, amount) = (Guid.NewGuid().ToString(), Random().Next(1, 15))
27 | let httpContext = buildMockHttpContext ()
28 | |> writeObjectToBody {Name = name; Amount = amount}
29 | // Act
30 | let http =
31 | task {
32 | let! ctxAfterHandler = HttpHandler.createStockItemHandler testRoot.CreateStockItem testRoot.GenerateId next httpContext
33 | return ctxAfterHandler
34 | } |> Async.AwaitTask |> Async.RunSynchronously |> Option.get
35 | // Assert
36 | http.Response.StatusCode |> should equal StatusCodes.Status201Created
37 | let createdId = http.Response.Headers.["Location"].ToString().[11..] |> Int64.Parse
38 | let httpContext4Query = buildMockHttpContext ()
39 | let httpAfterQuery =
40 | task {
41 | let! ctxAfterQuery = HttpHandler.queryStockItemHandler testRoot.QueryStockItemBy createdId next httpContext4Query
42 | return ctxAfterQuery
43 | } |> Async.AwaitTask |> Async.RunSynchronously |> Option.get
44 | let createdStockItem = httpAfterQuery.Response |> deserializeResponse
45 | createdStockItem.Id |> should equal createdId
46 | createdStockItem.Name |> should equal name
47 | createdStockItem.AvailableAmount |> should equal amount
48 |
--------------------------------------------------------------------------------
/Api/Api.fsproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net5.0
4 | Api.App
5 | false
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | PreserveNewest
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | PreserveNewest
36 |
37 |
38 |
--------------------------------------------------------------------------------
/Api/Dtos.fs:
--------------------------------------------------------------------------------
1 | namespace Api
2 |
3 | module Dtos =
4 | type RemoveStockItemDto = {
5 | Id: int64
6 | Amount: int
7 | }
8 |
9 | type CreateStockItemDto = {
10 | Name: string
11 | Amount: int
12 | }
13 |
14 |
--------------------------------------------------------------------------------
/Api/FlexibleCompositionRoot/FlexibleCompositionRoot.fs:
--------------------------------------------------------------------------------
1 | module FlexibleCompositionRoot
2 | open Api.FlexibleCompositionRoot
3 | open Stock.Application
4 | open Stock.StockItem
5 |
6 | type FlexibleCompositionRoot =
7 | { QueryStockItemBy: Queries.StockItemById.Query -> Async
8 | RemoveFromStock: int64 -> int -> Async>
9 | CreateStockItem: int64 -> string -> int -> Async
10 | GenerateId: unit -> int64
11 | }
12 |
13 | let compose (trunk: Trunk.Trunk) =
14 | {
15 | QueryStockItemBy = trunk.QueryStockItemBy
16 | RemoveFromStock = StockItemWorkflows.remove trunk.StockItemWorkflowDependencies
17 | CreateStockItem = StockItemWorkflows.create trunk.StockItemWorkflowDependencies
18 | GenerateId = trunk.GenerateId
19 | }
--------------------------------------------------------------------------------
/Api/FlexibleCompositionRoot/Leaves/StockItemWorkflowsDependencies.fs:
--------------------------------------------------------------------------------
1 | namespace Api.FlexibleCompositionRoot.Leaves
2 |
3 | open System.Data
4 | open Stock.Application
5 | open Stock.PostgresDao
6 |
7 | module StockItemWorkflowDependencies =
8 | let compose (createDbConnection: unit -> Async) : StockItemWorkflows.IO =
9 | {
10 | ReadBy = StockItemDao.readBy createDbConnection
11 | Update = StockItemDao.update createDbConnection
12 | Insert = StockItemDao.insert createDbConnection
13 | }
14 |
15 |
--------------------------------------------------------------------------------
/Api/FlexibleCompositionRoot/Trunk.fs:
--------------------------------------------------------------------------------
1 | namespace Api.FlexibleCompositionRoot
2 |
3 | open Settings
4 | open Stock.Application
5 | open Stock.PostgresDao
6 |
7 | module Trunk =
8 | type Trunk =
9 | {
10 | GenerateId: unit -> int64
11 | StockItemWorkflowDependencies: StockItemWorkflows.IO
12 | QueryStockItemBy: Queries.StockItemById.Query -> Async
13 | }
14 |
15 | let compose (settings: Settings) =
16 | let createDbConnection = DapperFSharp.createSqlConnection settings.SqlConnectionString
17 | {
18 | GenerateId = IdGenerator.create settings.IdGeneratorSettings
19 | StockItemWorkflowDependencies = Leaves.StockItemWorkflowDependencies.compose createDbConnection
20 | QueryStockItemBy = StockItemQueryDao.readBy createDbConnection
21 | // Your next application layer workflow dependencies ...
22 | }
23 |
24 |
25 |
--------------------------------------------------------------------------------
/Api/HttpHandler.fs:
--------------------------------------------------------------------------------
1 | namespace Api
2 |
3 | open Api.Dtos
4 | open FlexibleCompositionRoot
5 | open Stock.StockItem
6 | open Giraffe
7 | open Microsoft.AspNetCore.Http
8 | open FSharp.Control.Tasks.V2.ContextInsensitive
9 |
10 | module HttpHandler =
11 | let queryStockItemHandler queryStockItemBy (id: int64): HttpHandler =
12 | fun (next: HttpFunc) (ctx: HttpContext) ->
13 | task {
14 | let! stockItem = queryStockItemBy (id |> Queries.StockItemById.Query)
15 | return! match stockItem with
16 | | Some stockItem -> json stockItem next ctx
17 | | None -> RequestErrors.notFound (text "Not Found") next ctx
18 | }
19 |
20 | let removeFromStockItem (removeStockItem: int64 -> int -> Async>): HttpHandler =
21 | fun (next: HttpFunc) (ctx: HttpContext) ->
22 | task {
23 | let! stockItemDto = ctx.BindJsonAsync()
24 | let! bookingResult = removeStockItem stockItemDto.Id stockItemDto.Amount
25 | let response = match bookingResult with
26 | | Ok _ -> Successful.OK (text "OK") next ctx
27 | | Error message -> RequestErrors.badRequest (json message) next ctx
28 | return! response
29 | }
30 |
31 | let createStockItemHandler createStockItem (createId: unit -> int64): HttpHandler =
32 | fun (next: HttpFunc) (ctx: HttpContext) ->
33 | let id = createId()
34 | task {
35 | let! stockItemDto = ctx.BindJsonAsync()
36 | do! createStockItem id stockItemDto.Name stockItemDto.Amount
37 | ctx.SetHttpHeader "Location" (sprintf "/stockitem/%d" id)
38 | return! Successful.created (text "Created") next ctx
39 | }
40 |
41 | let router (compositionRoot: FlexibleCompositionRoot): HttpFunc -> HttpContext -> HttpFuncResult =
42 | choose [ GET >=> route "/" >=> htmlView Views.index
43 | GET >=> routef "/stockitem/%d" (queryStockItemHandler compositionRoot.QueryStockItemBy)
44 | PATCH >=> route "/stockitem/" >=> (removeFromStockItem compositionRoot.RemoveFromStock)
45 | POST >=> route "/stockitem/" >=> (createStockItemHandler compositionRoot.CreateStockItem compositionRoot.GenerateId)
46 | setStatusCode 404 >=> text "Not Found" ]
47 |
--------------------------------------------------------------------------------
/Api/IdGenerator.fs:
--------------------------------------------------------------------------------
1 | module IdGenerator
2 | open System
3 | open IdGen
4 | []
5 | type Settings =
6 | { GeneratorId: int
7 | Epoch: DateTimeOffset
8 | TimestampBits: byte
9 | GeneratorIdBits: byte
10 | SequenceBits: byte }
11 |
12 | let create settings =
13 | let structure = IdStructure(settings.TimestampBits, settings.GeneratorIdBits, settings.SequenceBits)
14 | let options = IdGeneratorOptions(structure, DefaultTimeSource(settings.Epoch))
15 | let generator = IdGenerator(settings.GeneratorId, options)
16 | fun() -> generator.CreateId()
--------------------------------------------------------------------------------
/Api/InflexibleCompositionRoot.fs:
--------------------------------------------------------------------------------
1 | module InflexibleCompositionRoot
2 |
3 | open Stock.StockItem
4 | open Stock.Application
5 | open Stock.PostgresDao
6 | open Settings
7 |
8 | type InflexibleCompositionRoot =
9 | { QueryStockItemBy: Queries.StockItemById.Query -> Async
10 | RemoveFromStock: int64 -> int -> Async>
11 | CreateStockItem: int64 -> string -> int -> Async
12 | GenerateId: unit -> int64
13 | }
14 |
15 | let compose settings =
16 | let createSqlConnection = DapperFSharp.createSqlConnection settings.SqlConnectionString
17 | let idGenerator = IdGenerator.create settings.IdGeneratorSettings
18 | let stockItemWorkflowsIo: StockItemWorkflows.IO = {
19 | ReadBy = StockItemDao.readBy createSqlConnection
20 | Update = StockItemDao.update createSqlConnection
21 | Insert = StockItemDao.insert createSqlConnection
22 | }
23 | {
24 | QueryStockItemBy = StockItemQueryDao.readBy createSqlConnection
25 | RemoveFromStock = StockItemWorkflows.remove stockItemWorkflowsIo
26 | CreateStockItem = StockItemWorkflows.create stockItemWorkflowsIo
27 | GenerateId = idGenerator
28 | }
29 |
--------------------------------------------------------------------------------
/Api/Program.fs:
--------------------------------------------------------------------------------
1 | module Api.App
2 |
3 | open System
4 | open System.IO
5 | open Microsoft.AspNetCore.Builder
6 | open Microsoft.AspNetCore.Hosting
7 | open Microsoft.Extensions.Configuration
8 | open Microsoft.Extensions.Hosting
9 | open Microsoft.Extensions.Logging
10 | open Microsoft.Extensions.DependencyInjection
11 | open Giraffe
12 | open Settings
13 | open FlexibleCompositionRoot
14 | open InflexibleCompositionRoot
15 |
16 | let errorHandler (ex : Exception) (logger : ILogger) =
17 | logger.LogError(ex, "An unhandled exception has occurred while executing the request.")
18 | clearResponse >=> setStatusCode 500 >=> text ex.Message
19 |
20 | let configureApp (compositionRoot: FlexibleCompositionRoot)
21 | (app : IApplicationBuilder) =
22 | let env = app.ApplicationServices.GetService()
23 | (match env.EnvironmentName with
24 | | "Development" -> app.UseDeveloperExceptionPage()
25 | | _ -> app.UseGiraffeErrorHandler(errorHandler))
26 | .UseHttpsRedirection()
27 | .UseStaticFiles()
28 | .UseGiraffe(HttpHandler.router compositionRoot)
29 |
30 | let configureServices (services : IServiceCollection) =
31 | services.AddGiraffe() |> ignore
32 |
33 | let configureLogging (builder : ILoggingBuilder) =
34 | builder.AddFilter(fun l -> l.Equals LogLevel.Error)
35 | .AddConsole()
36 | .AddDebug() |> ignore
37 |
38 | let configureSettings (configurationBuilder: IConfigurationBuilder) =
39 | configurationBuilder.SetBasePath(AppContext.BaseDirectory)
40 | .AddJsonFile("appsettings.json", false)
41 |
42 | []
43 | let main args =
44 | let contentRoot = Directory.GetCurrentDirectory()
45 | let webRoot = Path.Combine(contentRoot, "WebRoot")
46 | let confBuilder = ConfigurationBuilder() |> configureSettings
47 | //let root = InflexibleCompositionRoot.compose (confBuilder.Build().Get())
48 | let trunk = Trunk.compose (confBuilder.Build().Get())
49 | let root = FlexibleCompositionRoot.compose trunk
50 | Host.CreateDefaultBuilder(args)
51 | .ConfigureWebHostDefaults(
52 | fun webHostBuilder ->
53 | webHostBuilder
54 | .UseContentRoot(contentRoot)
55 | .UseWebRoot(webRoot)
56 | .Configure(Action (configureApp root))
57 | .ConfigureServices(configureServices)
58 | .ConfigureLogging(configureLogging)
59 | |> ignore)
60 | .Build()
61 | .Run()
62 | 0
--------------------------------------------------------------------------------
/Api/Settings.fs:
--------------------------------------------------------------------------------
1 | module Settings
2 |
3 | []
4 | type Settings = {
5 | IdGeneratorSettings: IdGenerator.Settings
6 | SqlConnectionString: string
7 | }
--------------------------------------------------------------------------------
/Api/Views.fs:
--------------------------------------------------------------------------------
1 | namespace Api
2 | module Views =
3 | open Giraffe.ViewEngine
4 |
5 | let index =
6 | html [] [
7 | head [] [ title [] [ encodedText "Whatever Api" ] ]
8 | body [] [ h1 [] [ encodedText "Whatever Api" ] ]
9 | ]
10 |
--------------------------------------------------------------------------------
/Api/WebRoot/main.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: Arial, Helvetica, sans-serif;
3 | color: #333;
4 | font-size: .9em;
5 | }
6 |
7 | h1 {
8 | font-size: 1.5em;
9 | color: #334499;
10 | }
--------------------------------------------------------------------------------
/Api/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "IdGeneratorSettings": {
3 | "GeneratorId": 0,
4 | "Epoch": "2020-10-01 12:30:00",
5 | "TimestampBits": 41,
6 | "GeneratorIdBits": 10,
7 | "SequenceBits": 12
8 | },
9 | "SqlConnectionString": "Host=localhost;User Id=postgres;Password=Secret!Passw0rd;Database=stock;Port=5432"
10 | }
11 |
--------------------------------------------------------------------------------
/FsharpComposition.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "Api\Api.fsproj", "{84D2EC9A-C947-4DEC-BD2B-58A9A85D5046}"
4 | EndProject
5 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Stock", "Stock\Stock.fsproj", "{A775E71E-4546-4204-9E82-5B8262F7416A}"
6 | EndProject
7 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_infra", "_infra", "{C17148A6-A01F-49FB-B2A8-797059C8E339}"
8 | ProjectSection(SolutionItems) = preProject
9 | README.md = README.md
10 | docker-compose.yml = docker-compose.yml
11 | database.sql = database.sql
12 | EndProjectSection
13 | EndProject
14 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Stock.PostgresDao", "Stock.PostgresDao\Stock.PostgresDao.fsproj", "{9DE5858F-3D0D-4923-962C-67AB2B18DC13}"
15 | EndProject
16 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{A11824D7-478B-4EC2-9CC3-AFBB64A77643}"
17 | EndProject
18 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Intragration", "Intragration\Intragration.fsproj", "{E0FCB837-4E04-4537-A188-F8AE4FE5AF44}"
19 | EndProject
20 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Acceptance", "Acceptance\Acceptance.fsproj", "{C8A4FC6C-FC35-4801-AC51-7D824141BC1B}"
21 | EndProject
22 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Unit", "Unit\Unit.fsproj", "{18034065-98B4-4B16-A669-6D4549E50308}"
23 | EndProject
24 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Toolbox", "Toolbox\Toolbox.fsproj", "{EF2FB0ED-290E-4B9A-9BA0-A18A123C67F3}"
25 | EndProject
26 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Stock.Application", "Stock.Application\Stock.Application.fsproj", "{CDB03743-7A7F-4BDF-AFEC-2EC35BB428C1}"
27 | EndProject
28 | Global
29 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
30 | Debug|Any CPU = Debug|Any CPU
31 | Release|Any CPU = Release|Any CPU
32 | EndGlobalSection
33 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
34 | {84D2EC9A-C947-4DEC-BD2B-58A9A85D5046}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
35 | {84D2EC9A-C947-4DEC-BD2B-58A9A85D5046}.Debug|Any CPU.Build.0 = Debug|Any CPU
36 | {84D2EC9A-C947-4DEC-BD2B-58A9A85D5046}.Release|Any CPU.ActiveCfg = Release|Any CPU
37 | {84D2EC9A-C947-4DEC-BD2B-58A9A85D5046}.Release|Any CPU.Build.0 = Release|Any CPU
38 | {A775E71E-4546-4204-9E82-5B8262F7416A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
39 | {A775E71E-4546-4204-9E82-5B8262F7416A}.Debug|Any CPU.Build.0 = Debug|Any CPU
40 | {A775E71E-4546-4204-9E82-5B8262F7416A}.Release|Any CPU.ActiveCfg = Release|Any CPU
41 | {A775E71E-4546-4204-9E82-5B8262F7416A}.Release|Any CPU.Build.0 = Release|Any CPU
42 | {9DE5858F-3D0D-4923-962C-67AB2B18DC13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
43 | {9DE5858F-3D0D-4923-962C-67AB2B18DC13}.Debug|Any CPU.Build.0 = Debug|Any CPU
44 | {9DE5858F-3D0D-4923-962C-67AB2B18DC13}.Release|Any CPU.ActiveCfg = Release|Any CPU
45 | {9DE5858F-3D0D-4923-962C-67AB2B18DC13}.Release|Any CPU.Build.0 = Release|Any CPU
46 | {E0FCB837-4E04-4537-A188-F8AE4FE5AF44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
47 | {E0FCB837-4E04-4537-A188-F8AE4FE5AF44}.Debug|Any CPU.Build.0 = Debug|Any CPU
48 | {E0FCB837-4E04-4537-A188-F8AE4FE5AF44}.Release|Any CPU.ActiveCfg = Release|Any CPU
49 | {E0FCB837-4E04-4537-A188-F8AE4FE5AF44}.Release|Any CPU.Build.0 = Release|Any CPU
50 | {C8A4FC6C-FC35-4801-AC51-7D824141BC1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
51 | {C8A4FC6C-FC35-4801-AC51-7D824141BC1B}.Debug|Any CPU.Build.0 = Debug|Any CPU
52 | {C8A4FC6C-FC35-4801-AC51-7D824141BC1B}.Release|Any CPU.ActiveCfg = Release|Any CPU
53 | {C8A4FC6C-FC35-4801-AC51-7D824141BC1B}.Release|Any CPU.Build.0 = Release|Any CPU
54 | {18034065-98B4-4B16-A669-6D4549E50308}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
55 | {18034065-98B4-4B16-A669-6D4549E50308}.Debug|Any CPU.Build.0 = Debug|Any CPU
56 | {18034065-98B4-4B16-A669-6D4549E50308}.Release|Any CPU.ActiveCfg = Release|Any CPU
57 | {18034065-98B4-4B16-A669-6D4549E50308}.Release|Any CPU.Build.0 = Release|Any CPU
58 | {EF2FB0ED-290E-4B9A-9BA0-A18A123C67F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
59 | {EF2FB0ED-290E-4B9A-9BA0-A18A123C67F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
60 | {EF2FB0ED-290E-4B9A-9BA0-A18A123C67F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
61 | {EF2FB0ED-290E-4B9A-9BA0-A18A123C67F3}.Release|Any CPU.Build.0 = Release|Any CPU
62 | {CDB03743-7A7F-4BDF-AFEC-2EC35BB428C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
63 | {CDB03743-7A7F-4BDF-AFEC-2EC35BB428C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
64 | {CDB03743-7A7F-4BDF-AFEC-2EC35BB428C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
65 | {CDB03743-7A7F-4BDF-AFEC-2EC35BB428C1}.Release|Any CPU.Build.0 = Release|Any CPU
66 | EndGlobalSection
67 | GlobalSection(NestedProjects) = preSolution
68 | {E0FCB837-4E04-4537-A188-F8AE4FE5AF44} = {A11824D7-478B-4EC2-9CC3-AFBB64A77643}
69 | {C8A4FC6C-FC35-4801-AC51-7D824141BC1B} = {A11824D7-478B-4EC2-9CC3-AFBB64A77643}
70 | {18034065-98B4-4B16-A669-6D4549E50308} = {A11824D7-478B-4EC2-9CC3-AFBB64A77643}
71 | {EF2FB0ED-290E-4B9A-9BA0-A18A123C67F3} = {A11824D7-478B-4EC2-9CC3-AFBB64A77643}
72 | EndGlobalSection
73 | EndGlobal
74 |
--------------------------------------------------------------------------------
/Intragration/Intragration.fsproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net5.0
4 | false
5 | false
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | runtime; build; native; contentfiles; analyzers; buildtransitive
17 | all
18 |
19 |
20 | runtime; build; native; contentfiles; analyzers; buildtransitive
21 | all
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/Intragration/Program.fs:
--------------------------------------------------------------------------------
1 | module Program = let [] main _ = 0
2 |
--------------------------------------------------------------------------------
/Intragration/StockItemDao.fs:
--------------------------------------------------------------------------------
1 | module Tests
2 |
3 | open System
4 | open Stock
5 | open Xunit
6 | open Stock.PostgresDao
7 | open StockItem
8 | open FsToolkit.ErrorHandling
9 | open FsUnit.Xunit
10 |
11 | []
12 | let ``GIVEN stock item WHEN inserted THEN after read it fully restored`` () =
13 | // Arrange
14 | let expectedStockItem: StockItem =
15 | { Id = Toolbox.generateId () |> StockItemId
16 | AvailableAmount = 4
17 | Name = Guid.NewGuid().ToString() }
18 | asyncResult {
19 | // Act
20 | do! StockItemDao.insert Toolbox.createSqlConnection expectedStockItem
21 | // Assert
22 | let! actualStockItem = StockItemDao.readBy Toolbox.createSqlConnection expectedStockItem.Id
23 | actualStockItem |> should equal expectedStockItem
24 | } |> Async.RunSynchronously
25 |
26 | []
27 | let ``GIVEN stock item WHEN updated THEN after read it has updated values`` () =
28 | // Arrange
29 | let initialStockItem: StockItem =
30 | { Id = Toolbox.generateId () |> StockItemId
31 | AvailableAmount = 4
32 | Name = Guid.NewGuid().ToString() }
33 | let modifiedStockItem = { initialStockItem with AvailableAmount = 2; Name = Guid.NewGuid.ToString() }
34 | asyncResult {
35 | do! StockItemDao.insert Toolbox.createSqlConnection initialStockItem
36 | // Act
37 | do! StockItemDao.update Toolbox.createSqlConnection modifiedStockItem
38 | // Assert
39 | let! actualStockItem = StockItemDao.readBy Toolbox.createSqlConnection modifiedStockItem.Id
40 | actualStockItem |> should not' (equal initialStockItem)
41 | actualStockItem |> should equal modifiedStockItem
42 | } |> Async.RunSynchronously
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | This project is part of [blog post on F# dependencies composition](https://mcode.it/blog/2020-12-11-fsharp_composition_root/).
4 |
5 | # Building
6 | You will need dotnet 5.0 SDK to build and run the project. You can download it from here: https://dotnet.microsoft.com/download/dotnet/5.0. Then just use standard dotnet commands to build, test and run.
7 |
--------------------------------------------------------------------------------
/Stock.Application/Queries/StockItemById.fs:
--------------------------------------------------------------------------------
1 | module Queries.StockItemById
2 |
3 | type Query = bigint
4 |
5 | type Result = {
6 | Id: int64
7 | Name: string
8 | AvailableAmount: int
9 | }
10 |
--------------------------------------------------------------------------------
/Stock.Application/Stock.Application.fsproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net5.0
4 | Stock.Application
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/Stock.Application/StockItemWorkflows.fs:
--------------------------------------------------------------------------------
1 | namespace Stock.Application
2 |
3 | open Stock.StockItem
4 | open FsToolkit.ErrorHandling
5 |
6 | module StockItemWorkflows =
7 | type IO = {
8 | ReadBy: StockItemId -> Async>
9 | Update: StockItem -> Async
10 | Insert: StockItem -> Async
11 | }
12 |
13 | let remove io id amount: Async> =
14 | asyncResult {
15 | let! stockItem = io.ReadBy (id |> StockItemId) |> AsyncResult.mapError(fun _ -> CannotReadStockItem)
16 | let! stockItem = remove stockItem amount
17 | do! io.Update stockItem
18 | }
19 |
20 | let create io id name capacity: Async =
21 | create (id |> StockItemId) name capacity
22 | |> io.Insert
--------------------------------------------------------------------------------
/Stock.PostgresDao/DapperFSharp.fs:
--------------------------------------------------------------------------------
1 | namespace Stock.PostgresDao
2 |
3 | open System.Data
4 | open Dapper
5 | open Npgsql
6 |
7 | module DapperFSharp =
8 |
9 | let sqlSingleOrNone<'Result> (query: string) (param: obj) (connection: IDbConnection): Async<'Result option> =
10 | async {
11 | let! result = connection.QuerySingleOrDefaultAsync<'Result>(query, param)
12 | |> Async.AwaitTask
13 | return match box result with
14 | | null -> None
15 | | _ -> Some result
16 | }
17 |
18 | let sqlSingle<'Result> (query: string) (param: obj) (connection: IDbConnection): Async<'Result> =
19 | async {
20 | let! result = connection.QuerySingleAsync<'Result>(query, param)
21 | |> Async.AwaitTask
22 | return result
23 | }
24 |
25 | let sqlExecute (sql: string) (param: obj) (connection: IDbConnection) =
26 | connection.ExecuteAsync(sql, param)
27 | |> Async.AwaitTask
28 | |> Async.Ignore
29 |
30 | let createSqlConnection (connectionString: string): unit -> Async =
31 | fun () -> async {
32 | let connection = new NpgsqlConnection(connectionString)
33 | if connection.State <> ConnectionState.Open
34 | then do! connection.OpenAsync() |> Async.AwaitTask
35 | return connection :> IDbConnection
36 | }
37 |
--------------------------------------------------------------------------------
/Stock.PostgresDao/Stock.PostgresDao.fsproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net5.0
4 | Hotel.PostgresDao
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Stock.PostgresDao/StockItemDao.fs:
--------------------------------------------------------------------------------
1 | namespace Stock.PostgresDao
2 |
3 | open Stock.StockItem
4 | open DapperFSharp
5 | open Thoth.Json.Net
6 |
7 | module StockItemDao =
8 | let readBy createConnection (id: StockItemId) =
9 | async {
10 | use! connection = createConnection ()
11 | let (StockItemId id) = id
12 | let! stockItemJson = connection |> sqlSingle "SELECT data FROM stockitems WHERE id = @id" {|id = id|}
13 | let stockItem = Decode.Auto.fromString(stockItemJson, CaseStrategy.PascalCase, Extra.empty |> Extra.withInt64)
14 | return stockItem
15 | }
16 |
17 | let insert createConnection (stockItem: StockItem) =
18 | async {
19 | use! connection = createConnection ()
20 | let (StockItemId id) = stockItem.Id
21 | let json = Encode.Auto.toString(4, stockItem, CaseStrategy.PascalCase, Extra.empty |> Extra.withInt64)
22 | do! connection |> sqlExecute "
23 | INSERT INTO stockitems
24 | (id, data)
25 | VALUES(@Id, @Data::jsonb);" {| Id = id; Data = json |}
26 | }
27 |
28 | let update createConnection stockItem =
29 | async {
30 | use! connection = createConnection ()
31 | let (StockItemId id) = stockItem.Id
32 | let json = Encode.Auto.toString(4, stockItem, CaseStrategy.PascalCase, Extra.empty |> Extra.withInt64)
33 | do! connection |> sqlExecute "
34 | UPDATE stockitems
35 | SET data = @Data::jsonb
36 | WHERE id = @Id" {| Id = id; Data = json |}
37 | }
38 |
--------------------------------------------------------------------------------
/Stock.PostgresDao/StockItemQueryDao.fs:
--------------------------------------------------------------------------------
1 | namespace Stock.PostgresDao
2 |
3 | open DapperFSharp
4 |
5 | module StockItemQueryDao =
6 |
7 | let readBy createConnection (id: Queries.StockItemById.Query) =
8 | async {
9 | use! connection = createConnection ()
10 | return! connection |> sqlSingleOrNone "
11 | SELECT Id,
12 | data ->> 'Name' AS Name,
13 | (data ->> 'AvailableAmount')::int AS AvailableAmount
14 | FROM stockitems
15 | WHERE Id = @Id
16 | " {|Id = id |> int64|}
17 | }
--------------------------------------------------------------------------------
/Stock/Stock.fsproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net5.0
4 | preview
5 | Hotels
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Stock/StockItem.fs:
--------------------------------------------------------------------------------
1 | namespace Stock
2 |
3 | module StockItem =
4 |
5 | type StockItemErrors = | OutOfStock | CannotReadStockItem
6 | type StockItemId = StockItemId of int64
7 |
8 | type StockItem = {
9 | Id: StockItemId
10 | Name: string
11 | AvailableAmount: int
12 | }
13 |
14 | let create id name amount =
15 | { Id = id; Name = name; AvailableAmount = amount }
16 |
17 | let (|GreaterThan|_|) k value = if value <= k then Some() else None
18 |
19 | let remove (stockItem: StockItem) amount: Result =
20 | match amount with
21 | | GreaterThan stockItem.AvailableAmount ->
22 | { stockItem with AvailableAmount = stockItem.AvailableAmount - amount } |> Ok
23 | | _ -> OutOfStock |> Error
--------------------------------------------------------------------------------
/Toolbox/HttpContext.fs:
--------------------------------------------------------------------------------
1 | module HttpContext
2 |
3 | open System.Collections.Generic
4 | open System.Threading.Tasks
5 | open FakeItEasy
6 | open Giraffe
7 | open Giraffe.Serialization
8 | open Microsoft.AspNetCore.Http
9 | open System.IO
10 | open System.Text
11 | open Microsoft.Extensions.Primitives
12 | open Microsoft.FSharpLu.Json
13 | open Newtonsoft.Json
14 |
15 | let next: HttpFunc = Some >> Task.FromResult
16 |
17 | let buildMockHttpContext () =
18 | let emptyHeaders: IHeaderDictionary =
19 | HeaderDictionary(Dictionary()) :> IHeaderDictionary
20 | let customSettings = JsonSerializerSettings()
21 | customSettings.Converters.Add(CompactUnionJsonConverter(true))
22 | let context = A.Fake()
23 | A.CallTo(fun () -> context.RequestServices.GetService(typeof))
24 | .Returns(DefaultNegotiationConfig())
25 | |> ignore
26 | A.CallTo(fun () -> context.RequestServices.GetService(typeof))
27 | .Returns(NewtonsoftJsonSerializer(customSettings))
28 | |> ignore
29 | A.CallTo(fun () -> context.Response.Headers).Returns(emptyHeaders)
30 | |> ignore
31 | context.Response.Body <- new MemoryStream()
32 | context
33 |
34 | let writeObjectToBody obj (httpContext: HttpContext) =
35 | let json = JsonConvert.SerializeObject(obj)
36 | let stream =
37 | new MemoryStream(Encoding.UTF8.GetBytes(json))
38 | httpContext.Request.Body <- stream
39 | httpContext
40 |
41 | let deserializeResponse<'T> (response: HttpResponse) =
42 | response.Body.Position <- 0L
43 | use reader =
44 | new StreamReader(response.Body, Encoding.UTF8)
45 | let bodyJson = reader.ReadToEnd()
46 | let customSettings = JsonSerializerSettings()
47 | customSettings.Converters.Add(CompactUnionJsonConverter(true))
48 | JsonConvert.DeserializeObject<'T>(bodyJson, customSettings)
49 |
--------------------------------------------------------------------------------
/Toolbox/Toolbox.fs:
--------------------------------------------------------------------------------
1 | module Toolbox
2 |
3 | open System
4 | open System.Data
5 | open IdGen
6 | open Npgsql
7 |
8 | let createSqlConnection: unit -> Async =
9 | fun () -> async {
10 | let connection = new NpgsqlConnection("Host=localhost;User Id=postgres;Password=Secret!Passw0rd;Database=stock;Port=5432")
11 | if connection.State <> ConnectionState.Open
12 | then do! connection.OpenAsync() |> Async.AwaitTask
13 | return connection :> IDbConnection
14 | }
15 |
16 | let generateId =
17 | let structure = IdStructure(byte 41, byte 10, byte 12)
18 | let options = IdGeneratorOptions(structure, DefaultTimeSource(DateTimeOffset.Parse "2020-10-01 12:30:00"))
19 | let generator = IdGenerator(666, options)
20 | fun() -> generator.CreateId()
--------------------------------------------------------------------------------
/Toolbox/Toolbox.fsproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net5.0
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/Unit/Program.fs:
--------------------------------------------------------------------------------
1 | module Program = let [] main _ = 0
2 |
--------------------------------------------------------------------------------
/Unit/StockItem.fs:
--------------------------------------------------------------------------------
1 | module Tests
2 |
3 | open Xunit
4 | open Stock.StockItem
5 | open FsUnit.Xunit
6 |
7 | []
8 | let ``GIVEN amount to remove is greater than available stock item amount WHEN remove THEN OutOfStock error is returned`` () =
9 | // Arrange
10 | let expectedError: Result = OutOfStock |> Result.Error
11 |
12 | let stockItem =
13 | { Id = 200 |> int64 |> StockItemId
14 | Name = "Whatever"
15 | AvailableAmount = 1 }
16 | // Act
17 | let result = remove stockItem 10
18 | // Assert
19 | result |> should equal expectedError
20 |
21 | []
22 | []
23 | []
24 | []
25 | let ``GIVEN amount to remove is equal or less than available stock item amount WHEN remove THEN StockItem available amount`` (amountToRemove) =
26 | // Arrange
27 | let stockItemUnderTest =
28 | { Id = 200 |> int64 |> StockItemId
29 | Name = "Whatever"
30 | AvailableAmount = 4 }
31 | let expectedStockItem: Result =
32 | { Id = stockItemUnderTest.Id
33 | Name = stockItemUnderTest.Name
34 | AvailableAmount = stockItemUnderTest.AvailableAmount - amountToRemove }
35 | |> Result.Ok
36 | // Act
37 | let result = remove stockItemUnderTest amountToRemove
38 | // Assert
39 | result |> should equal expectedStockItem
40 |
--------------------------------------------------------------------------------
/Unit/Unit.fsproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net5.0
4 | false
5 | false
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | runtime; build; native; contentfiles; analyzers; buildtransitive
17 | all
18 |
19 |
20 | runtime; build; native; contentfiles; analyzers; buildtransitive
21 | all
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/database.sql:
--------------------------------------------------------------------------------
1 | create database stock;
2 | create table stockItems (
3 | id bigint constraint pk_stockItems primary key,
4 | data json not null
5 | );
6 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 | services:
3 | db:
4 | image: postgres
5 | restart: always
6 | environment:
7 | POSTGRES_PASSWORD: Secret!Passw0rd
8 | POSTGRES_USER: postgres
9 | ports:
10 | - 5432:5432
--------------------------------------------------------------------------------