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 @@ - +