diff --git a/src/Symbolica.Extensions.Configuration.FSharp/Config.fs b/src/Symbolica.Extensions.Configuration.FSharp/Config.fs
index 452b441..2b73e67 100644
--- a/src/Symbolica.Extensions.Configuration.FSharp/Config.fs
+++ b/src/Symbolica.Extensions.Configuration.FSharp/Config.fs
@@ -9,7 +9,7 @@ open Microsoft.Extensions.Configuration
///
/// A that retrieves the section specified by the .
///
-/// Will evaluate to Failure if the cannot be found.
+/// Will evaluate to Failure if the cannot be found.
/// The key of the child section to retrieve.
let section key : Binder<'config, IConfigurationSection, Error> =
Binder (fun (parent: #IConfiguration) ->
@@ -23,7 +23,7 @@ let section key : Binder<'config, IConfigurationSection, Error> =
///
/// A that retrieves the optional section specified by the .
///
-/// Will evaluate to Success(None) if the cannot be found.
+/// Will evaluate to Success(None) if the cannot be found.
/// The key of the child section to retrieve.
let optSection key =
Binder (fun (parent: #IConfiguration) ->
diff --git a/src/Symbolica.Extensions.Configuration.FSharp/Error.fs b/src/Symbolica.Extensions.Configuration.FSharp/Error.fs
index 777e2f0..3e5f22d 100644
--- a/src/Symbolica.Extensions.Configuration.FSharp/Error.fs
+++ b/src/Symbolica.Extensions.Configuration.FSharp/Error.fs
@@ -1,5 +1,7 @@
namespace Symbolica.Extensions.Configuration.FSharp
+open System
+
module private String =
let indent by string =
$"""{" " |> String.replicate (2 * by)}{string}"""
@@ -111,9 +113,9 @@ type Errors<'a> =
let printedErrors =
errors
|> List.map (printItem (indent + 1))
- |> String.concat "\n"
+ |> String.concat Environment.NewLine
- $"{key |> String.indent indent}:\n{printedErrors}"
+ $"{key |> String.indent indent}:{Environment.NewLine}{printedErrors}"
module Errors =
/// Maps the errors contained within the .
@@ -196,14 +198,14 @@ type Error =
member x.ToString(indent) =
(match x with
| SectionError (key, error) ->
- $"@'{key}':\n{error.ToString(indent + 1)}"
+ $"@'{key}':{Environment.NewLine}{error.ToString(indent + 1)}"
|> String.indent indent
| Many errors -> errors.ToString(indent, (fun i x -> x.ToString(i)))
| ValueError (value, error) ->
[ $"Value: '{value}'"
- $"Error:\n{error.ToString(indent + 1)}" ]
+ $"Error:{Environment.NewLine}{error.ToString(indent + 1)}" ]
|> List.map (String.indent indent)
- |> String.concat "\n"
+ |> String.concat Environment.NewLine
| NotAValueNode ->
"Expected a value, but found a section with children."
|> String.indent indent
diff --git a/tests/Symbolica.Extensions.Configuration.FSharp.Tests/BuilderTests.fs b/tests/Symbolica.Extensions.Configuration.FSharp.Tests/BuilderTests.fs
deleted file mode 100644
index f4456eb..0000000
--- a/tests/Symbolica.Extensions.Configuration.FSharp.Tests/BuilderTests.fs
+++ /dev/null
@@ -1,142 +0,0 @@
-module Symbolica.Extensions.Configuration.FSharp.Builders
-
-open FsCheck
-open Swensen.Unquote
-open global.Xunit
-
-type SubOptions =
- { OptionalNumber: float option
- Bool: bool }
-
-type Options =
- { Name: string
- SubOptions: SubOptions
- OptSubOptions: SubOptions option }
-
-let mkOptions config =
- let bindSubOptions =
- bind {
- let! optionalNumber = Bind.optValueAt "MaybeDecimal" Bind.float
- and! bool = Bind.valueAt "bool" Bind.bool
-
- return
- { OptionalNumber = optionalNumber
- Bool = bool }
- }
-
- Bind.section
- "Options"
- (bind {
- let! name = Bind.valueAt "Name" Bind.string
- and! subOptions = Bind.section "Sub" bindSubOptions
- and! optSubOptions = Bind.optSection "OptSub" bindSubOptions
-
- return
- { Name = name
- SubOptions = subOptions
- OptSubOptions = optSubOptions }
- })
- |> Binder.eval config
-
-[]
-let ``should bind when configuration correct`` () =
- let config =
- { Children =
- [ { Children =
- [ { Children = Seq.empty
- Path = ConfigPathSegment "Name"
- Value = "A name" }
- { Children =
- [ { Children = Seq.empty
- Path = ConfigPathSegment "MaybeDecimal"
- Value = "1.0" }
- { Children = Seq.empty
- Path = ConfigPathSegment "bool"
- Value = "true" } ]
- Path = ConfigPathSegment "Sub"
- Value = null }
- { Children =
- [ { Children = Seq.empty
- Path = ConfigPathSegment "MaybeDecimal"
- Value = "2.0" }
- { Children = Seq.empty
- Path = ConfigPathSegment "bool"
- Value = "false" } ]
- Path = ConfigPathSegment "OptSub"
- Value = null } ]
- Path = ConfigPathSegment "Options"
- Value = null } ]
- Path = ConfigPathSegment.empty
- Value = null }
-
- test
- <@ config |> mkOptions = Success(
- { Name = "A name"
- SubOptions =
- { OptionalNumber = Some(1.0)
- Bool = true }
- OptSubOptions =
- { OptionalNumber = Some(2.0)
- Bool = false }
- |> Some }
- ) @>
-
-[]
-let ``should bind successfully when optional config missing`` () =
- let config =
- { Children =
- [ { Children =
- [ { Children = Seq.empty
- Path = ConfigPathSegment "Name"
- Value = "A name" }
- { Children =
- [ { Children = Seq.empty
- Path = ConfigPathSegment "MaybeDecimal"
- Value = null }
- { Children = Seq.empty
- Path = ConfigPathSegment "bool"
- Value = "true" } ]
- Path = ConfigPathSegment "Sub"
- Value = null } ]
- Path = ConfigPathSegment "Options"
- Value = null } ]
- Path = ConfigPathSegment.empty
- Value = null }
-
- test
- <@ config |> mkOptions = Success(
- { Name = "A name"
- SubOptions = { OptionalNumber = None; Bool = true }
- OptSubOptions = None }
- ) @>
-
-[]
-let ``should fail when non optional config missing`` () =
- let config =
- { Children =
- [ { Children =
- [ { Children = Seq.empty
- Path = ConfigPathSegment "NotTheNameKey"
- Value = "A name" }
- { Children = Seq.empty
- Path = ConfigPathSegment "Sub"
- Value = null } ]
- Path = ConfigPathSegment "Options"
- Value = null } ]
- Path = ConfigPathSegment.empty
- Value = null }
-
- let expected =
- Failure(
- Error.SectionError(
- "Options",
- Error.Many(
- Errors.AllOf(
- Error.keyNotFound "Name"
- +& Error.keyNotFound "Sub"
- )
- )
- )
- )
-
- test <@ config |> mkOptions = expected @>
diff --git a/tests/Symbolica.Extensions.Configuration.FSharp.Tests/IntegrationTests.fs b/tests/Symbolica.Extensions.Configuration.FSharp.Tests/IntegrationTests.fs
new file mode 100644
index 0000000..2a8fa56
--- /dev/null
+++ b/tests/Symbolica.Extensions.Configuration.FSharp.Tests/IntegrationTests.fs
@@ -0,0 +1,204 @@
+module Symbolica.Extensions.Configuration.FSharp.IntegrationTests
+
+open Microsoft.Extensions.Configuration
+open Swensen.Unquote
+open Xunit
+
+[]
+type LogLevel =
+ | Info
+ | Debug
+ | Warning
+ | Error
+
+module LogLevel =
+ let bind =
+ Binder(
+ fun (s: string) -> s.ToLowerInvariant()
+ >> (function
+ | "info" -> Success LogLevel.Info
+ | "debug" -> Success LogLevel.Debug
+ | "warning" -> Success LogLevel.Warning
+ | "error" -> Success LogLevel.Error
+ | _ -> Failure ValueError.invalidType)
+ )
+
+type ILogSink =
+ abstract Level: LogLevel option
+
+type ConsoleSink =
+ { Level: LogLevel option }
+ interface ILogSink with
+ member x.Level = x.Level
+
+module ConsoleSink =
+ let bind =
+ bind {
+ let! level = Bind.optValueAt "Level" LogLevel.bind
+ return { Level = level }
+ }
+
+module Bytes =
+ let bind<[] 'u> (units: string) =
+ Binder (fun (s: string) ->
+ if s.EndsWith(units) then
+ s.Substring(0, s.Length - 1) |> Success
+ else
+ Failure(ValueError.Message $"Expected bytes value to end with '{units}'."))
+ |> Binder.extend Bind.int
+ |> Binder.map LanguagePrimitives.Int32WithMeasure<'u>
+
+[]
+type B
+
+module B =
+ let bind = Bytes.bind "B"
+
+[]
+type KB
+
+module KB =
+ let bind = Bytes.bind "KB"
+ let toBytes (x: int) = x * 1000
+
+[]
+type MB
+
+module MB =
+ let bind = Bytes.bind "MB"
+ let toKiloBytes (x: int) = x * 1000
+ let toBytes = toKiloBytes >> KB.toBytes
+
+type FileSink =
+ { Level: LogLevel option
+ MaxFileSize: int }
+ interface ILogSink with
+ member x.Level = x.Level
+
+module FileSink =
+ let bind =
+ bind {
+ let! level = Bind.optValueAt "Level" LogLevel.bind
+
+ and! maxFileSize =
+ Bind.valueAt
+ "MaxFileSize"
+ (Bind.oneValueOf (
+ MB.bind |> Binder.map MB.toBytes
+ <|> (KB.bind |> Binder.map KB.toBytes)
+ <|> B.bind
+ ))
+
+ return
+ { Level = level
+ MaxFileSize = maxFileSize }
+ }
+
+type AppInsightsSink =
+ { Level: LogLevel option
+ InstrumentationKey: string }
+ interface ILogSink with
+ member x.Level = x.Level
+
+module AppInsightsSink =
+ let bind =
+ bind {
+ let! level = Bind.optValueAt "Level" LogLevel.bind
+ and! instrumentationKey = Bind.valueAt "InstrumentationKey" Bind.string
+
+ return
+ { Level = level
+ InstrumentationKey = instrumentationKey }
+ }
+
+type LoggingOptions =
+ { DefaultLevel: LogLevel
+ Sinks: ILogSink list }
+
+module LoggingOptions =
+
+ let toILogSinkBinder b =
+ b |> Binder.map (fun s -> s :> ILogSink)
+
+ let bind =
+ bind {
+ let! defaultLevel = Bind.valueAt "DefaultLevel" LogLevel.bind
+
+ and! sinks =
+ Bind.optSection
+ "Sinks"
+ ([ Bind.optSection "Console" (ConsoleSink.bind |> toILogSinkBinder)
+ Bind.optSection "File" (FileSink.bind |> toILogSinkBinder)
+ Bind.optSection "AppInsights" (AppInsightsSink.bind |> toILogSinkBinder) ]
+ |> Bind.allOf
+ |> Binder.map (List.choose id))
+
+ return
+ { DefaultLevel = defaultLevel
+ Sinks = sinks |> Option.defaultValue [] }
+ }
+
+let mkConfig config =
+ Bind.section "Logging" LoggingOptions.bind
+ |> Binder.eval (
+ ConfigurationBuilder()
+ .AddInMemoryCollection(config |> Map.ofList)
+ .Build()
+ )
+ |> BindResult.mapFailure (fun e -> e.ToString())
+
+[]
+let ``should bind successfully without any sinks`` () =
+ test
+ <@ [ "Logging:DefaultLevel", "Warning" ] |> mkConfig = Success(
+ { DefaultLevel = LogLevel.Warning
+ Sinks = [] }
+ ) @>
+
+[]
+let ``should bind successfully with some sinks`` () =
+ test
+ <@ [ "Logging:DefaultLevel", "Warning"
+ "Logging:Sinks:File:MaxFileSize", "1024B"
+ "Logging:Sinks:AppInsights:Level", "Error"
+ "Logging:Sinks:AppInsights:InstrumentationKey", "super-secret-key" ]
+ |> mkConfig = Success(
+ { DefaultLevel = LogLevel.Warning
+ Sinks =
+ [ { Level = None; MaxFileSize = 1024 }
+ { Level = Some LogLevel.Error
+ InstrumentationKey = "super-secret-key" } ] }
+ ) @>
+
+[]
+let ``should fail with pretty message if config empty`` () =
+ test
+ <@ [] |> mkConfig = Failure(
+ """@'Logging':
+ The key was not found."""
+ ) @>
+
+[]
+let ``should fail with pretty message if level and a sink is invalid`` () =
+ test
+ <@ [ "Logging:DefaultLevel", "NotALevel"
+ "Logging:Sinks:File:MaxFileSize", "NotBytes" ]
+ |> mkConfig = Failure(
+ """@'Logging':
+ all of these:
+ @'DefaultLevel':
+ Value: 'NotALevel'
+ Error:
+ Could not parse value as type 'LogLevel'.
+ @'Sinks':
+ all of these:
+ @'File':
+ all of these:
+ @'MaxFileSize':
+ Value: 'NotBytes'
+ Error:
+ one of these:
+ Expected bytes value to end with 'MB'.
+ Expected bytes value to end with 'KB'.
+ Expected bytes value to end with 'B'."""
+ ) @>
diff --git a/tests/Symbolica.Extensions.Configuration.FSharp.Tests/Symbolica.Extensions.Configuration.FSharp.Tests.fsproj b/tests/Symbolica.Extensions.Configuration.FSharp.Tests/Symbolica.Extensions.Configuration.FSharp.Tests.fsproj
index d516a08..fab9ed3 100644
--- a/tests/Symbolica.Extensions.Configuration.FSharp.Tests/Symbolica.Extensions.Configuration.FSharp.Tests.fsproj
+++ b/tests/Symbolica.Extensions.Configuration.FSharp.Tests/Symbolica.Extensions.Configuration.FSharp.Tests.fsproj
@@ -9,6 +9,7 @@
+
@@ -22,6 +23,6 @@
-
+