From f2a80b8711f9b8cd41a6d69129cdc71413d627e0 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Tue, 4 Jan 2022 17:03:42 +0000 Subject: [PATCH 01/14] Cherry-pick from #69 --- .../UnionConverterTests.fs | 4 ++ .../FsCodec.SystemTextJson.Tests.fsproj | 1 - .../TypeSafeEnumConverterTests.fs | 47 ------------------- 3 files changed, 4 insertions(+), 48 deletions(-) delete mode 100644 tests/FsCodec.SystemTextJson.Tests/TypeSafeEnumConverterTests.fs diff --git a/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs b/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs index 41723b5..b9b1443 100644 --- a/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs +++ b/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs @@ -487,6 +487,7 @@ module ``Struct discriminated unions`` = | CaseAV of av : TestRecordPayloadStruct | CaseB | CaseC of string + | CaseC2 of c2: int | CaseD of d : string | CaseE of e : string * int | CaseF of f : string * fb : int @@ -511,6 +512,9 @@ module ``Struct discriminated unions`` = let c = CaseC "hi" test <@ """{"case":"CaseC","Item":"hi"}""" = serialize c @> + let c2 = CaseC2 2 + test <@ """{"case":"CaseC2","c2":2}""" = serialize c2 @> + let d = CaseD "hi" test <@ """{"case":"CaseD","d":"hi"}""" = serialize d @> diff --git a/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj b/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj index b6ec900..0c867d8 100644 --- a/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj +++ b/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj @@ -27,7 +27,6 @@ - Fixtures.fs diff --git a/tests/FsCodec.SystemTextJson.Tests/TypeSafeEnumConverterTests.fs b/tests/FsCodec.SystemTextJson.Tests/TypeSafeEnumConverterTests.fs deleted file mode 100644 index d8dbffc..0000000 --- a/tests/FsCodec.SystemTextJson.Tests/TypeSafeEnumConverterTests.fs +++ /dev/null @@ -1,47 +0,0 @@ -module FsCodec.SystemTextJson.Tests.TypeSafeEnumConverterTests - -open FsCodec.SystemTextJson -open System -open System.Collections.Generic -open System.Text.Json -open Swensen.Unquote -open Xunit - -type Outcome = Joy | Pain | Misery - -let [] happy () = - test <@ box Joy = TypeSafeEnum.parseT (typeof) "Joy" @> - test <@ Joy = TypeSafeEnum.parse "Joy" @> - test <@ box Joy = TypeSafeEnum.parseT (typeof) "Joy" @> - test <@ None = TypeSafeEnum.tryParse "Wat" @> - raises <@ TypeSafeEnum.parse "Wat" @> - - let optionsWithOutcomeConverter = Options.Create(TypeSafeEnumConverter()) - test <@ Joy = Serdes.Deserialize("\"Joy\"", optionsWithOutcomeConverter) @> - test <@ Some Joy = Serdes.Deserialize("\"Joy\"", optionsWithOutcomeConverter) @> - raises <@ Serdes.Deserialize("\"Confusion\"", optionsWithOutcomeConverter) @> - // Was a JsonException prior to V6 - raises <@ Serdes.Deserialize "1" @> - -let [] sad () = - raises <@ TypeSafeEnum.tryParse "Wat" @> - raises <@ TypeSafeEnum.toString "Wat" @> - -[)>] -type OutcomeWithOther = Joy | Pain | Misery | Other -and OutcomeWithCatchAllConverter() = - inherit JsonIsomorphism() - override _.Pickle v = - TypeSafeEnum.toString v - - override _.UnPickle json = - json - |> TypeSafeEnum.tryParse - |> Option.defaultValue Other - -let [] fallBackExample () = - test <@ Joy = Serdes.Deserialize "\"Joy\"" @> - test <@ Some Other = Serdes.Deserialize "\"Wat\"" @> - test <@ Other = Serdes.Deserialize "\"Wat\"" @> - raises <@ Serdes.Deserialize "1" @> - test <@ Seq.forall (fun (x,y) -> x = y) <| Seq.zip [Joy; Other] (Serdes.Deserialize "[\"Joy\", \"Wat\"]") @> From 07e8874dcedf881ec14c6e1299820b8e8ac4f7ce Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 5 Jan 2022 13:45:58 +0000 Subject: [PATCH 02/14] Make Serdes stateful --- README.md | 31 +++++++----- src/FsCodec.NewtonsoftJson/Serdes.fs | 28 +++++++---- src/FsCodec.SystemTextJson/Options.fs | 5 +- src/FsCodec.SystemTextJson/Serdes.fs | 26 ++++++---- .../FsCodec.NewtonsoftJson.Tests/Examples.fsx | 23 ++++----- .../SomeNullHandlingTests.fs | 25 +++++----- .../UnionConverterTests.fs | 46 +++++++++-------- .../CodecTests.fs | 5 +- .../FsCodec.SystemTextJson.Tests/Examples.fsx | 27 +++++----- .../PicklerTests.fs | 18 ++++--- .../SerdesTests.fs | 49 ++++++++++--------- .../UmxInteropTests.fs | 5 +- 12 files changed, 164 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index 181eb48..9cd7e2a 100644 --- a/README.md +++ b/README.md @@ -114,10 +114,10 @@ The respective concrete Codec packages include relevant `Converter`/`JsonConvert ## `Serdes` -[`FsCodec.NewtonsoftJson.Serdes`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/Serdes.fs#L7) provides light wrappers over `JsonConvert.(Des|S)erializeObject` that utilize the serialization profile defined by `Settings/Options.Create` (above). Methods: +[`FsCodec.SystemTextJson/NewtonsoftJson.Serdes`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.SystemTextJson/Serdes.fs#L7) provides light wrappers over `JsonConvert.(Des|S)erializeObject` based on a supplied serialization profile created by `Settings/Options.Create` (above). Methods: - `Serialize`: serializes an object per its type using the settings defined in `Settings/Options.Create` - `Deserialize`: deserializes an object per its type using the settings defined in `Settings/Options.Create` -- `DefaultSettings` / `DefaultOptions`: Allows one to access a global static instance of the `JsonSerializerSettings`/`JsonSerializerOptions` used by the default profile. +- `Options`: Allows one to access the `JsonSerializerSettings`/`JsonSerializerOptions` used by this instance. # Usage of Converters with ASP.NET Core @@ -137,23 +137,26 @@ If you follow the policies covered in the rest of the documentation here, your D ## ASP.NET Core with `Newtonsoft.Json` Hence the following represents the recommended default policy:- + /// Define a Serdes instance with a given policy somewhere (globally if you need to do explicit JSON generation) + let serdes = Settings.Create() |> Serdes + services.AddMvc(fun options -> ... ).AddNewtonsoftJson(fun options -> - FsCodec.NewtonsoftJson.Serdes.DefaultSettings.Converters - |> Seq.iter options.SerializerSettings.Converters.Add + serdes.Options.Converters |> Seq.iter options.SerializerSettings.Converters.Add ) |> ignore -This adds all the converters used by the default `Serdes` mechanism (currently only `FsCodec.NewtonsoftJson.OptionConverter`), and add them to any imposed by other configuration logic. +This adds all the converters used by the `serdes` serialization/deserialization policy (currently only `FsCodec.NewtonsoftJson.OptionConverter`) into the equivalent managed by ASP.NET. ## ASP.NET Core with `System.Text.Json` The equivalent for the native `System.Text.Json` looks like this: + let serdes = FsCodec.SystemTextJson.Options.Create() |> FsCodec.SystemTextJson.Serdes + services.AddMvc(fun options -> ... ).AddJsonOptions(fun options -> - FsCodec.SystemTextJson.Serdes.DefaultOptions.Converters - |> Seq.iter options.JsonSerializerOptions.Converters.Add + serdes.Options.Converters |> Seq.iter options.JsonSerializerOptions.Converters.Add ) |> ignore _As of `System.Text.Json` v6, thanks [to the great work of the .NET team](https://github.com/dotnet/runtime/pull/55108), the above is presently a no-op._ @@ -165,7 +168,7 @@ There's a test playground in [tests/FsCodec.NewtonsoftJson.Tests/Examples.fsx](t There's an equivalent of that for `FsCodec.SystemTextJson`: [tests/FsCodec.SystemTextJson.Tests/Examples.fsx](tests/FsCodec.SystemTextJson.Tests/Examples.fsx). -### Examples of using `Settings` and `Serdes` to define a contract +### Examples of using `Serdes` to define a contract In a contract assembly used as a way to supply types as part of a client library, one way of encapsulating the conversion rules that need to be applied is as follows: @@ -176,10 +179,12 @@ The minimal code needed to define helpers to consistently roundtrip where one on ```fsharp module Contract = type Item = { value : string option } + /// Settings to be used within this contract (opinionated ones compared to just using JsonConvert.SerializeObject / DeserializeObject) + let private serdes = FsCodec.NewtonsoftJson.Settings() |> FsCodec.NewtonsoftJson.Serdes // implies default settings from Settings.Create(), which includes OptionConverter - let serialize (x : Item) : string = FsCodec.NewtonsoftJson.Serdes.Serialize x + let serialize (x : Item) : string = serdes.Serialize x // implies default settings from Settings.Create(), which includes OptionConverter - let deserialize (json : string) = FsCodec.NewtonsoftJson.Serdes.Deserialize json + let deserialize (json : string) = serdes.Deserialize json ``` #### More advanced case necessitating a custom converter @@ -190,9 +195,9 @@ While it's hard to justify the wrapping in the previous case, this illustrates h module Contract = type Item = { value : string option; other : TypeThatRequiresMyCustomConverter } /// Settings to be used within this contract - let settings = FsCodec.NewtonsoftJson.Settings.Create(converters = [| MyCustomConverter() |]) - let serialize (x : Item) = FsCodec.NewtonsoftJson.Serdes.Serialize(x,settings) - let deserialize (json : string) : Item = FsCodec.NewtonsoftJson.Serdes.Deserialize(json,settings) + let private serdes = FsCodec.NewtonsoftJson.Settings.Create(converters = [| MyCustomConverter() |]) |> FsCodec.NewtonsoftJson.Serdes + let serialize (x : Item) = serdes.Serialize x + let deserialize (json : string) : Item = serdes.Deserialize json ``` ## Encoding and conversion of F# types diff --git a/src/FsCodec.NewtonsoftJson/Serdes.fs b/src/FsCodec.NewtonsoftJson/Serdes.fs index 11eee89..c40c13e 100755 --- a/src/FsCodec.NewtonsoftJson/Serdes.fs +++ b/src/FsCodec.NewtonsoftJson/Serdes.fs @@ -3,25 +3,32 @@ namespace FsCodec.NewtonsoftJson open Newtonsoft.Json open System.Runtime.InteropServices -/// Serializes to/from strings using the settings arising from a call to Settings.Create() -type Serdes private () = +/// Serializes to/from strings using the supplied Settings +type Serdes(options : JsonSerializerSettings) = - static let defaultSettings = lazy Settings.Create() - static let indentSettings = lazy Settings.Create(indent = true) + /// The Settings used by this instance. + member _.Options : JsonSerializerSettings = options - /// Yields the settings used by Serdes when no settings are supplied. - static member DefaultSettings : JsonSerializerSettings = defaultSettings.Value + /// Serializes given value to a JSON string. + member _.Serialize<'T>(value : 'T) = + JsonConvert.SerializeObject(value, options) + + /// Deserializes value of given type from JSON string. + member x.Deserialize<'T>(json : string) : 'T = + JsonConvert.DeserializeObject<'T>(json, options) /// Serializes given value to a JSON string. + [] static member Serialize<'T> ( /// Value to serialize. value : 'T, /// Use indentation when serializing JSON. Defaults to false. [] ?indent : bool) : string = - let settings = (if defaultArg indent false then indentSettings else defaultSettings).Value - Serdes.Serialize<'T>(value, settings) + let options = (if indent = Some true then Settings.Create(indent = true) else Settings.Create()) + JsonConvert.SerializeObject(value, options) - /// Serializes given value to a JSON string with custom settings + /// Serializes given value to a JSON string with custom options + [] static member Serialize<'T> ( /// Value to serialize. value : 'T, @@ -30,10 +37,11 @@ type Serdes private () = JsonConvert.SerializeObject(value, settings) /// Deserializes value of given type from JSON string. + [] static member Deserialize<'T> ( /// Json string to deserialize. json : string, /// Settings to use (defaults to Settings.Create() profile) [] ?settings : JsonSerializerSettings) : 'T = - let settings = match settings with None -> defaultSettings.Value | Some x -> x + let settings = match settings with Some x -> x | None -> Settings.Create() JsonConvert.DeserializeObject<'T>(json, settings) diff --git a/src/FsCodec.SystemTextJson/Options.fs b/src/FsCodec.SystemTextJson/Options.fs index 9e2682b..35c43c8 100755 --- a/src/FsCodec.SystemTextJson/Options.fs +++ b/src/FsCodec.SystemTextJson/Options.fs @@ -48,7 +48,10 @@ type Options private () = /// Ignore null values in input data, don't render fields with null values; defaults to `false`. [] ?ignoreNulls : bool, /// Drop escaping of HTML-sensitive characters. defaults to `true`. - [] ?unsafeRelaxedJsonEscaping : bool) = + [] ?unsafeRelaxedJsonEscaping : bool, + /// Apply convention-based Union conversion using TypeSafeEnumConverter if possible, or UnionEncoder for all Discriminated Unions. + /// defaults to false. + [] ?autoUnion : bool) = Options.CreateDefault( converters = converters, diff --git a/src/FsCodec.SystemTextJson/Serdes.fs b/src/FsCodec.SystemTextJson/Serdes.fs index 4e885e0..b226015 100755 --- a/src/FsCodec.SystemTextJson/Serdes.fs +++ b/src/FsCodec.SystemTextJson/Serdes.fs @@ -3,25 +3,32 @@ namespace FsCodec.SystemTextJson open System.Runtime.InteropServices open System.Text.Json -/// Serializes to/from strings using the Options arising from a call to Options.Create() -type Serdes private () = +/// Serializes to/from strings using the supplied Options +type Serdes(options : JsonSerializerOptions) = - static let defaultOptions = lazy Options.Create() - static let indentOptions = lazy Options.Create(indent = true) + /// The JsonSerializerOptions used by this instance. + member _.Options : JsonSerializerOptions = options - /// Yields the settings used by Serdes when no options are supplied. - static member DefaultOptions : JsonSerializerOptions = defaultOptions.Value + /// Serializes given value to a JSON string. + member _.Serialize<'T>(value : 'T) = + JsonSerializer.Serialize<'T>(value, options) + + /// Deserializes value of given type from JSON string. + member x.Deserialize<'T>(json : string) : 'T = + JsonSerializer.Deserialize<'T>(json, options) /// Serializes given value to a JSON string. + [] static member Serialize<'T> ( /// Value to serialize. value : 'T, /// Use indentation when serializing JSON. Defaults to false. [] ?indent : bool) : string = - let options = (if defaultArg indent false then indentOptions else defaultOptions).Value - Serdes.Serialize<'T>(value, options) + let options = (if indent = Some true then Options.Create(indent = true) else Options.Create()) + JsonSerializer.Serialize<'T>(value, options) /// Serializes given value to a JSON string with custom options + [] static member Serialize<'T> ( /// Value to serialize. value : 'T, @@ -30,10 +37,11 @@ type Serdes private () = JsonSerializer.Serialize<'T>(value, options) /// Deserializes value of given type from JSON string. + [] static member Deserialize<'T> ( /// Json string to deserialize. json : string, /// Options to use (defaults to Options.Create() profile) [] ?options : JsonSerializerOptions) : 'T = - let settings = match options with None -> defaultOptions.Value | Some x -> x + let settings = options |> Option.defaultWith Options.Create JsonSerializer.Deserialize<'T>(json, settings) diff --git a/tests/FsCodec.NewtonsoftJson.Tests/Examples.fsx b/tests/FsCodec.NewtonsoftJson.Tests/Examples.fsx index df20f31..2515124 100755 --- a/tests/FsCodec.NewtonsoftJson.Tests/Examples.fsx +++ b/tests/FsCodec.NewtonsoftJson.Tests/Examples.fsx @@ -11,10 +11,10 @@ open System module Contract = type Item = { value : string option } - // implies default settings from Settings.Create(), which includes OptionConverter - let serialize (x : Item) : string = FsCodec.NewtonsoftJson.Serdes.Serialize x - // implies default settings from Settings.Create(), which includes OptionConverter - let deserialize (json : string) = FsCodec.NewtonsoftJson.Serdes.Deserialize json + // implies an OptionConverter will be applied + let private serdes = FsCodec.NewtonsoftJson.Settings.Create() |> FsCodec.NewtonsoftJson.Serdes + let serialize (x : Item) : string = serdes.Serialize x + let deserialize (json : string) = serdes.Deserialize json module Contract2 = @@ -23,12 +23,13 @@ module Contract2 = type Item = { value : string option; other : TypeThatRequiresMyCustomConverter } /// Settings to be used within this contract // note OptionConverter is also included by default - let settings = FsCodec.NewtonsoftJson.Settings.Create(converters = [| MyCustomConverter() |]) - let serialize (x : Item) = FsCodec.NewtonsoftJson.Serdes.Serialize(x,settings) - let deserialize (json : string) : Item = FsCodec.NewtonsoftJson.Serdes.Deserialize(json,settings) + let private serdes = FsCodec.NewtonsoftJson.Settings.Create(converters = [| MyCustomConverter() |]) |> FsCodec.NewtonsoftJson.Serdes + let serialize (x : Item) = serdes.Serialize x + let deserialize (json : string) : Item = serdes.Deserialize json -let inline ser x = Serdes.Serialize(x) -let inline des<'t> x = Serdes.Deserialize<'t>(x) +let private serdes = FsCodec.NewtonsoftJson.Settings.Create() |> FsCodec.NewtonsoftJson.Serdes +let inline ser x = serdes.Serialize(x) +let inline des<'t> x = serdes.Deserialize<'t>(x) (* Global vs local Converters @@ -49,8 +50,8 @@ ser { a = "testing"; b = Guid.Empty } ser Guid.Empty // "00000000-0000-0000-0000-000000000000" -let settings = Settings.Create(converters = [| GuidConverter() |]) -Serdes.Serialize(Guid.Empty, settings) +let serdesWithGuidConverter = Settings.Create(converters = [| GuidConverter() |]) |> Serdes +serdesWithGuidConverter.Serialize(Guid.Empty) // 00000000000000000000000000000000 (* TypeSafeEnumConverter basic usage *) diff --git a/tests/FsCodec.NewtonsoftJson.Tests/SomeNullHandlingTests.fs b/tests/FsCodec.NewtonsoftJson.Tests/SomeNullHandlingTests.fs index 738a44c..baa9e3b 100644 --- a/tests/FsCodec.NewtonsoftJson.Tests/SomeNullHandlingTests.fs +++ b/tests/FsCodec.NewtonsoftJson.Tests/SomeNullHandlingTests.fs @@ -4,20 +4,21 @@ open FsCodec.NewtonsoftJson open Swensen.Unquote open Xunit -let def = Settings.CreateDefault() +let ootb = Settings.CreateDefault() |> Serdes +let serdes = Settings.Create() |> Serdes let [] ``Settings.CreateDefault roundtrips null string option, but rendering is ugly`` () = let value : string option = Some null - let ser = Serdes.Serialize(value, def) + let ser = ootb.Serialize value test <@ ser = "{\"Case\":\"Some\",\"Fields\":[null]}" @> - test <@ value = Serdes.Deserialize(ser, def) @> + test <@ value = ootb.Deserialize ser @> let [] ``Settings.Create does not roundtrip Some null`` () = let value : string option = Some null - let ser = Serdes.Serialize value + let ser = serdes.Serialize value "null" =! ser // But it doesn't roundtrip - value <>! Serdes.Deserialize ser + value <>! serdes.Deserialize ser let hasSomeNull value = TypeShape.Generic.exists(fun (x : string option) -> x = Some null) value let replaceSomeNullsWithNone value = TypeShape.Generic.map (function Some (null : string) -> None | x -> x) value @@ -31,10 +32,10 @@ let [] ``Workaround is to detect and/or substitute such non-roundtrippable let value : string option = replaceSomeNullsWithNone value None =! value test <@ (not << hasSomeNull) value @> - let ser = Serdes.Serialize value + let ser = serdes.Serialize value ser =! "null" // ... and validate that the [substituted] value did roundtrip - test <@ value = Serdes.Deserialize ser @> + test <@ value = serdes.Deserialize ser @> type RecordWithStringOptions = { x : int; y : Nested } and Nested = { z : string option } @@ -44,12 +45,12 @@ let [] ``Can detect and/or substitute null string option when using Settin test <@ hasSomeNull value @> let value = replaceSomeNullsWithNone value test <@ (not << hasSomeNull) value @> - let ser = Serdes.Serialize value + let ser = serdes.Serialize value ser =! """{"x":9,"y":{"z":null}}""" - test <@ value = Serdes.Deserialize ser @> + test <@ value = serdes.Deserialize ser @> // As one might expect, the ignoreNulls setting is also honored - let ignoreNullsSettings = Settings.Create(ignoreNulls=true) - let ser = Serdes.Serialize(value,ignoreNullsSettings) + let ignoreNullsSerdes = Settings.Create(ignoreNulls=true) |> Serdes + let ser = ignoreNullsSerdes.Serialize value ser =! """{"x":9,"y":{}}""" - test <@ value = Serdes.Deserialize ser @> + test <@ value = serdes.Deserialize ser @> diff --git a/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs b/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs index b9b1443..0f67961 100644 --- a/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs +++ b/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs @@ -539,6 +539,12 @@ module ``Struct discriminated unions`` = test <@ """{"case":"CaseIV","iv":{"test":"hi"},"ibv":"bye"}""" = serialize i @> #endif +#if SYSTEM_TEXT_JSON +let serdes = Options.Create() |> Serdes +#else +let serdes = Settings.Create() |> Serdes +#endif + module Nested = #if SYSTEM_TEXT_JSON @@ -598,45 +604,45 @@ module Nested = | V2 let [] ``can nest`` (value : U) = - let ser = Serdes.Serialize value - test <@ value = Serdes.Deserialize ser @> + let ser = serdes.Serialize value + test <@ value = serdes.Deserialize ser @> let [] ``nesting Unions represents child as item`` () = let v : U = U.C(UUA.B 42) - let ser = Serdes.Serialize v + let ser = serdes.Serialize v """{"case":"C","Item":{"case2":"B","Item":42}}""" =! ser - test <@ v = Serdes.Deserialize ser @> + test <@ v = serdes.Deserialize ser @> let [] ``TypeSafeEnum converts direct`` () = let v : U = U.C (UUA.E E.V1) - let ser = Serdes.Serialize v + let ser = serdes.Serialize v """{"case":"C","Item":{"case2":"E","Item":"V1"}}""" =! ser - test <@ v = Serdes.Deserialize ser @> + test <@ v = serdes.Deserialize ser @> let v : U = U.E E.V2 - let ser = Serdes.Serialize v + let ser = serdes.Serialize v """{"case":"E","Item":"V2"}""" =! ser - test <@ v = Serdes.Deserialize ser @> + test <@ v = serdes.Deserialize ser @> let v : U = U.EA [|E.V2; E.V2|] - let ser = Serdes.Serialize v + let ser = serdes.Serialize v """{"case":"EA","Item":["V2","V2"]}""" =! ser - test <@ v = Serdes.Deserialize ser @> + test <@ v = serdes.Deserialize ser @> let v : U = U.C (UUA.EO (Some E.V1)) - let ser = Serdes.Serialize v + let ser = serdes.Serialize v """{"case":"C","Item":{"case2":"EO","Item":"V1"}}""" =! ser - test <@ v = Serdes.Deserialize ser @> + test <@ v = serdes.Deserialize ser @> let v : U = U.C (UUA.EO None) - let ser = Serdes.Serialize v + let ser = serdes.Serialize v """{"case":"C","Item":{"case2":"EO","Item":null}}""" =! ser - test <@ v = Serdes.Deserialize ser @> + test <@ v = serdes.Deserialize ser @> let v : U = U.C UUA.S - let ser = Serdes.Serialize v + let ser = serdes.Serialize v """{"case":"C","Item":{"case2":"S"}}""" =! ser - test <@ v = Serdes.Deserialize ser @> + test <@ v = serdes.Deserialize ser @> /// And for everything else, JsonIsomorphism allows plenty ways of customizing the encoding and/or decoding module IsomorphismUnionEncoder = @@ -670,10 +676,10 @@ module IsomorphismUnionEncoder = let [] ``Can control the encoding to the nth degree`` () = let v : Top = N (B 42) - let ser = Serdes.Serialize v + let ser = serdes.Serialize v """{"disc":"TB","v":42}""" =! ser - test <@ v = Serdes.Deserialize ser @> + test <@ v = serdes.Deserialize ser @> let [] ``can roundtrip`` (value : Top) = - let ser = Serdes.Serialize value - test <@ value = Serdes.Deserialize ser @> + let ser = serdes.Serialize value + test <@ value = serdes.Deserialize ser @> diff --git a/tests/FsCodec.SystemTextJson.Tests/CodecTests.fs b/tests/FsCodec.SystemTextJson.Tests/CodecTests.fs index 61c7462..739d196 100644 --- a/tests/FsCodec.SystemTextJson.Tests/CodecTests.fs +++ b/tests/FsCodec.SystemTextJson.Tests/CodecTests.fs @@ -38,7 +38,8 @@ let [] roundtrips value = let enveloped = { d = encoded } // the options should be irrelevant, but use the defaults (which would add nulls in that we don't want if it was leaking) - let ser = FsCodec.SystemTextJson.Serdes.Serialize enveloped + let serdes = Options.Create() |> Serdes + let ser = serdes.Serialize enveloped match embedded with | Choice1Of2 { embed = null } @@ -53,7 +54,7 @@ let [] roundtrips value = | Choice1Of2 _ -> test <@ ser.StartsWith """{"d":{"embed":""" && not (ser.Contains "\"opt\"") @> - let des = FsCodec.SystemTextJson.Serdes.Deserialize ser + let des = serdes.Deserialize ser let wrapped = FsCodec.Core.TimelineEvent.Create(-1L, eventType, des.d) let decoded = eventCodec.TryDecode wrapped |> Option.get diff --git a/tests/FsCodec.SystemTextJson.Tests/Examples.fsx b/tests/FsCodec.SystemTextJson.Tests/Examples.fsx index 6cd9c65..953d995 100755 --- a/tests/FsCodec.SystemTextJson.Tests/Examples.fsx +++ b/tests/FsCodec.SystemTextJson.Tests/Examples.fsx @@ -22,23 +22,24 @@ open System module Contract = type Item = { value : string option } - // implies default options from Options.Create() - let serialize (x : Item) : string = FsCodec.SystemTextJson.Serdes.Serialize x - // implies default options from Options.Create() - let deserialize (json : string) = FsCodec.SystemTextJson.Serdes.Deserialize json + // while no converter actually gets applied as STJ v6 handles Options out of the box, this makes it explicit that we have a policy + let private serdes = FsCodec.SystemTextJson.Options.Create() |> FsCodec.SystemTextJson.Serdes + let serialize (x : Item) = serdes.Serialize x + let deserialize (json : string) = serdes.Deserialize json module Contract2 = type TypeThatRequiresMyCustomConverter = { mess : int } type MyCustomConverter() = inherit JsonPickler() override _.Read(_,_) = "" override _.Write(_,_,_) = () type Item = { value : string option; other : TypeThatRequiresMyCustomConverter } - /// Options to be used within this contract - let options = FsCodec.SystemTextJson.Options.Create(converters = [| MyCustomConverter() |]) - let serialize (x : Item) = FsCodec.SystemTextJson.Serdes.Serialize(x, options) - let deserialize (json : string) : Item = FsCodec.SystemTextJson.Serdes.Deserialize(json, options) + /// Note we add a custom converter here + let private serdes = FsCodec.SystemTextJson.Options.Create(converters = [| MyCustomConverter() |]) |> FsCodec.SystemTextJson.Serdes + let serialize (x : Item) = serdes.Serialize x + let deserialize (json : string) = serdes.Deserialize json -let inline ser x = Serdes.Serialize(x) -let inline des<'t> x = Serdes.Deserialize<'t>(x) +let private serdes = Options.Create() |> Serdes +let inline ser x = serdes.Serialize x +let inline des<'t> x = serdes.Deserialize<'t> x (* Global vs local Converters @@ -59,8 +60,8 @@ ser { a = "testing"; b = Guid.Empty } ser Guid.Empty // "00000000-0000-0000-0000-000000000000" -let options = Options.Create(converters = [| GuidConverter() |]) -Serdes.Serialize(Guid.Empty, options) +let serdesWithGuidConverter = Options.Create(converters = [| GuidConverter() |]) |> Serdes +serdesWithGuidConverter.Serialize Guid.Empty // 00000000000000000000000000000000 (* TypeSafeEnumConverter basic usage *) @@ -164,7 +165,7 @@ module Events = open FsCodec -let enc (s : string) = Serdes.Deserialize(s) +let enc (s : string) = serdes.Deserialize s let events = [ StreamName.parse "Favorites-ClientA", FsCodec.Core.TimelineEvent.Create(0L, "Added", enc """{ "item": "a" }""") StreamName.parse "Favorites-ClientB", FsCodec.Core.TimelineEvent.Create(0L, "Added", enc """{ "item": "b" }""") diff --git a/tests/FsCodec.SystemTextJson.Tests/PicklerTests.fs b/tests/FsCodec.SystemTextJson.Tests/PicklerTests.fs index 4991db0..46b6531 100644 --- a/tests/FsCodec.SystemTextJson.Tests/PicklerTests.fs +++ b/tests/FsCodec.SystemTextJson.Tests/PicklerTests.fs @@ -32,29 +32,31 @@ type Configs() as this = let [)>] ``Tagging with GuidConverter roundtrips`` (options : JsonSerializerOptions) = let value = { a = "testing"; b = Guid.Empty } - - let result = Serdes.Serialize(value, options) + let profile = Serdes options + let result = profile.Serialize value test <@ """{"a":"testing","b":"00000000000000000000000000000000"}""" = result @> - let des = Serdes.Deserialize(result, options) + let des = profile.Deserialize result test <@ value = des @> +let serdes = Serdes(Options.Create()) + let [] ``Global GuidConverter roundtrips`` () = let value = Guid.Empty - let defaultHandlingHasDashes = Serdes.Serialize value + let defaultHandlingHasDashes = serdes.Serialize value - let optionsWithConverter = Options.Create(GuidConverter()) - let resNoDashes = Serdes.Serialize(value, optionsWithConverter) + let profileWithConverter = Options.Create(GuidConverter()) |> Serdes + let resNoDashes = profileWithConverter.Serialize value test <@ "\"00000000-0000-0000-0000-000000000000\"" = defaultHandlingHasDashes && "\"00000000000000000000000000000000\"" = resNoDashes @> // Non-dashed is not accepted by default handling in STJ (Newtonsoft does accept it) - raises <@ Serdes.Deserialize resNoDashes @> + raises <@ serdes.Deserialize resNoDashes @> // With the converter, things roundtrip either way for result in [defaultHandlingHasDashes; resNoDashes] do - let des = Serdes.Deserialize(result, optionsWithConverter) + let des = profileWithConverter.Deserialize result test <@ value= des @> diff --git a/tests/FsCodec.SystemTextJson.Tests/SerdesTests.fs b/tests/FsCodec.SystemTextJson.Tests/SerdesTests.fs index 533990e..e63700a 100644 --- a/tests/FsCodec.SystemTextJson.Tests/SerdesTests.fs +++ b/tests/FsCodec.SystemTextJson.Tests/SerdesTests.fs @@ -12,30 +12,30 @@ type RecordWithOption = { a : int; b : string option } /// Characterization tests for OOTB JSON.NET /// The aim here is to characterize the gaps that we'll shim; we only want to do that as long as it's actually warranted module StjCharacterization = - let ootbOptions = Options.CreateDefault() + let ootb = Options.CreateDefault() |> Serdes let [] ``OOTB STJ records Just Works`` () = // Ver 5.x includes standard support for calling a single ctor (4.x required a custom implementation) let value = { a = 1 } - let ser = Serdes.Serialize(value, ootbOptions) + let ser = ootb.Serialize value test <@ ser = """{"a":1}""" @> - let res = Serdes.Deserialize(ser, ootbOptions) + let res = ootb.Deserializeser test <@ res = value @> let [] ``OOTB STJ options Just Works`` () = let value = { a = 1; b = Some "str" } - let ser = Serdes.Serialize(value, ootbOptions) + let ser = ootb.Serialize value test <@ ser = """{"a":1,"b":"str"}""" @> - test <@ value = Serdes.Deserialize(ser, ootbOptions) @> + test <@ value = ootb.Deserialize ser @> let [] ``OOTB STJ lists Just Works`` () = let value = [ "A"; "B" ] - let ser = Serdes.Serialize(value, ootbOptions) + let ser = ootb.Serialize value test <@ ser = """["A","B"]""" @> - test <@ value = Serdes.Deserialize(ser, ootbOptions) @> + test <@ value = ootb.Deserialize ser @> // System.Text.Json's JsonSerializerOptions by default escapes HTML-sensitive characters when generating JSON strings // while this arguably makes sense as a default @@ -51,40 +51,43 @@ module StjCharacterization = this.Add(Options.Create(unsafeRelaxedJsonEscaping = false)) let [)>] ``provides various ways to use HTML-escaped encoding``(opts : System.Text.Json.JsonSerializerOptions) = let value = { a = 1; b = Some "\"" } - let ser = Serdes.Serialize(value, opts) + let serdes = Serdes opts + let ser = serdes.Serialize value test <@ ser = """{"a":1,"b":"\u0022"}""" @> - let des = Serdes.Deserialize(ser, opts) + let des = serdes.Deserialize ser test <@ value = des @> (* Serdes + default Options behavior, i.e. the stuff we do *) +let serdes = Options.Create() |> Serdes + let [] records () = let value = { a = 1 } - let res = Serdes.Serialize value + let res = serdes.Serialize value test <@ res = """{"a":1}""" @> - let des = Serdes.Deserialize res + let des = serdes.Deserialize res test <@ value = des @> let [] arrays () = let value = [|"A"; "B"|] - let res = Serdes.Serialize value + let res = serdes.Serialize value test <@ res = """["A","B"]""" @> - let des = Serdes.Deserialize res + let des = serdes.Deserialize res test <@ value = des @> let [] options () = let value : RecordWithOption = { a = 1; b = Some "str" } - let ser = Serdes.Serialize value + let ser = serdes.Serialize value test <@ ser = """{"a":1,"b":"str"}""" @> - let des = Serdes.Deserialize ser + let des = serdes.Deserialize ser test <@ value = des @> // For maps, represent the value as an IDictionary<'K, 'V> or Dictionary and parse into a model as appropriate let [] maps () = let value = Map(seq { "A",1; "b",2 }) - let ser = Serdes.Serialize> value + let ser = serdes.Serialize> value test <@ ser = """{"A":1,"b":2}""" @> - let des = Serdes.Deserialize> ser + let des = serdes.Deserialize> ser test <@ value = Map.ofSeq (des |> Seq.map (|KeyValue|)) @> type RecordWithArrayOption = { str : string; arr : string[] option } @@ -95,18 +98,18 @@ type RecordWithArrayVOption = { str : string; arr : string[] voption } // A supported way of managing this is by wrapping the array in an `option` let [] ``array options`` () = let value = [|"A"; "B"|] - let res = Serdes.Serialize value + let res = serdes.Serialize value test <@ res = """["A","B"]""" @> - let des = Serdes.Deserialize res + let des = serdes.Deserialize res test <@ Some value = des @> - let des = Serdes.Deserialize "null" + let des = serdes.Deserialize "null" test <@ None = des @> - let des = Serdes.Deserialize "{}" + let des = serdes.Deserialize "{}" test <@ { str = null; arr = ValueNone } = des @> let [] ``Switches off the HTML over-escaping mechanism`` () = let value = { a = 1; b = Some "\"+" } - let ser = Serdes.Serialize value + let ser = serdes.Serialize value test <@ ser = """{"a":1,"b":"\"+"}""" @> - let des = Serdes.Deserialize ser + let des = serdes.Deserialize ser test <@ value = des @> diff --git a/tests/FsCodec.SystemTextJson.Tests/UmxInteropTests.fs b/tests/FsCodec.SystemTextJson.Tests/UmxInteropTests.fs index 8fdcf8a..b7c8146 100644 --- a/tests/FsCodec.SystemTextJson.Tests/UmxInteropTests.fs +++ b/tests/FsCodec.SystemTextJson.Tests/UmxInteropTests.fs @@ -30,7 +30,8 @@ let [)>] let value = Guid.Empty - let result = Serdes.Serialize(value, options) + let serdes = Serdes options + let result = serdes.Serialize value test <@ expectedSer = result @> - let des = Serdes.Deserialize(result, options) + let des = serdes.Deserialize result test <@ value = des @> From 9691f1ef715cdb44b4e597b9f7640ec5ae6fb018 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 5 Jan 2022 13:50:21 +0000 Subject: [PATCH 03/14] Add serdes to speller dictionary --- FsCodec.sln.DotSettings | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 FsCodec.sln.DotSettings diff --git a/FsCodec.sln.DotSettings b/FsCodec.sln.DotSettings new file mode 100644 index 0000000..324ee6d --- /dev/null +++ b/FsCodec.sln.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file From 954fea0211f99902485c0270e15e5c5475cef4f9 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 5 Jan 2022 14:01:12 +0000 Subject: [PATCH 04/14] Fix up #rs --- src/FsCodec.NewtonsoftJson/Serdes.fs | 2 +- tests/FsCodec.NewtonsoftJson.Tests/Examples.fsx | 11 +++++++++++ tests/FsCodec.SystemTextJson.Tests/Examples.fsx | 6 ++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/FsCodec.NewtonsoftJson/Serdes.fs b/src/FsCodec.NewtonsoftJson/Serdes.fs index c40c13e..138cc48 100755 --- a/src/FsCodec.NewtonsoftJson/Serdes.fs +++ b/src/FsCodec.NewtonsoftJson/Serdes.fs @@ -6,7 +6,7 @@ open System.Runtime.InteropServices /// Serializes to/from strings using the supplied Settings type Serdes(options : JsonSerializerSettings) = - /// The Settings used by this instance. + /// The JsonSerializerSettings used by this instance. member _.Options : JsonSerializerSettings = options /// Serializes given value to a JSON string. diff --git a/tests/FsCodec.NewtonsoftJson.Tests/Examples.fsx b/tests/FsCodec.NewtonsoftJson.Tests/Examples.fsx index 2515124..b0e3933 100755 --- a/tests/FsCodec.NewtonsoftJson.Tests/Examples.fsx +++ b/tests/FsCodec.NewtonsoftJson.Tests/Examples.fsx @@ -1,8 +1,19 @@ // Compile the fsproj by either a) right-clicking or b) typing // dotnet build tests/FsCodec.NewtonsoftJson.Tests before attempting to send this to FSI with Alt-Enter +#if USE_LOCAL_BUILD +#I "bin/Debug/net5.0" +#r "FsCodec.dll" +#r "Newtonsoft.Json.dll" +#r "FsCodec.NewtonsoftJson.dll" +#r "TypeShape.dll" +#r "FSharp.UMX.dll" +#r "Serilog.dll" +#r "Serilog.Sinks.Console.dll" +#else #r "nuget: FsCodec.NewtonsoftJson" #r "nuget: Serilog.Sinks.Console" +#endif open FsCodec.NewtonsoftJson open Newtonsoft.Json diff --git a/tests/FsCodec.SystemTextJson.Tests/Examples.fsx b/tests/FsCodec.SystemTextJson.Tests/Examples.fsx index 953d995..1f95b04 100755 --- a/tests/FsCodec.SystemTextJson.Tests/Examples.fsx +++ b/tests/FsCodec.SystemTextJson.Tests/Examples.fsx @@ -1,18 +1,20 @@ // Compile the fsproj by either a) right-clicking or b) typing // dotnet build tests/FsCodec.SystemTextJson.Tests before attempting to send this to FSI with Alt-Enter +#if USE_LOCAL_BUILD (* Rider's FSI is not happy without the explicit references :shrug: *) - #I "bin/Debug/net5.0" #r "FsCodec.dll" +//#r "System.Text.Json.dll" // Does not work atm :( #r "FsCodec.SystemTextJson.dll" #r "TypeShape.dll" #r "FSharp.UMX.dll" #r "Serilog.dll" #r "Serilog.Sinks.Console.dll" - +#else #r "nuget: FsCodec.SystemTextJson" #r "nuget: Serilog.Sinks.Console" +#endif open FsCodec.SystemTextJson open System.Text.Json From db0b07776e26af4717592e5a87f94e7fc33e50cb Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 5 Jan 2022 14:25:50 +0000 Subject: [PATCH 05/14] Tidy --- README.md | 2 +- .../UnionConverterTests.fs | 4 ---- tests/FsCodec.SystemTextJson.Tests/PicklerTests.fs | 12 ++++++------ 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9cd7e2a..985d5e9 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ The respective concrete Codec packages include relevant `Converter`/`JsonConvert ## `Serdes` -[`FsCodec.SystemTextJson/NewtonsoftJson.Serdes`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.SystemTextJson/Serdes.fs#L7) provides light wrappers over `JsonConvert.(Des|S)erializeObject` based on a supplied serialization profile created by `Settings/Options.Create` (above). Methods: +[`FsCodec.SystemTextJson/NewtonsoftJson.Serdes`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.SystemTextJson/Serdes.fs#L7) provides light wrappers over `(JsonConvert|JsonSerializer).(Des|S)erialize(Object)?` based on an explicitly supplied serialization profile created by `Settings/Options.Create` (above). This enables one to smoothly switch between `System.Text.Json` vs `Newtonsoft.Json` serializers with minimal application code changes, while also ensuring consistent and correct options get applied in each case. Methods: - `Serialize`: serializes an object per its type using the settings defined in `Settings/Options.Create` - `Deserialize`: deserializes an object per its type using the settings defined in `Settings/Options.Create` - `Options`: Allows one to access the `JsonSerializerSettings`/`JsonSerializerOptions` used by this instance. diff --git a/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs b/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs index 0f67961..c82b334 100644 --- a/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs +++ b/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs @@ -487,7 +487,6 @@ module ``Struct discriminated unions`` = | CaseAV of av : TestRecordPayloadStruct | CaseB | CaseC of string - | CaseC2 of c2: int | CaseD of d : string | CaseE of e : string * int | CaseF of f : string * fb : int @@ -512,9 +511,6 @@ module ``Struct discriminated unions`` = let c = CaseC "hi" test <@ """{"case":"CaseC","Item":"hi"}""" = serialize c @> - let c2 = CaseC2 2 - test <@ """{"case":"CaseC2","c2":2}""" = serialize c2 @> - let d = CaseD "hi" test <@ """{"case":"CaseD","d":"hi"}""" = serialize d @> diff --git a/tests/FsCodec.SystemTextJson.Tests/PicklerTests.fs b/tests/FsCodec.SystemTextJson.Tests/PicklerTests.fs index 46b6531..13e57fa 100644 --- a/tests/FsCodec.SystemTextJson.Tests/PicklerTests.fs +++ b/tests/FsCodec.SystemTextJson.Tests/PicklerTests.fs @@ -32,12 +32,12 @@ type Configs() as this = let [)>] ``Tagging with GuidConverter roundtrips`` (options : JsonSerializerOptions) = let value = { a = "testing"; b = Guid.Empty } - let profile = Serdes options - let result = profile.Serialize value + let serdes = Serdes options + let result = serdes.Serialize value test <@ """{"a":"testing","b":"00000000000000000000000000000000"}""" = result @> - let des = profile.Deserialize result + let des = serdes.Deserialize result test <@ value = des @> let serdes = Serdes(Options.Create()) @@ -47,8 +47,8 @@ let [] ``Global GuidConverter roundtrips`` () = let defaultHandlingHasDashes = serdes.Serialize value - let profileWithConverter = Options.Create(GuidConverter()) |> Serdes - let resNoDashes = profileWithConverter.Serialize value + let serdesWithConverter = Options.Create(GuidConverter()) |> Serdes + let resNoDashes = serdesWithConverter.Serialize value test <@ "\"00000000-0000-0000-0000-000000000000\"" = defaultHandlingHasDashes && "\"00000000000000000000000000000000\"" = resNoDashes @> @@ -58,5 +58,5 @@ let [] ``Global GuidConverter roundtrips`` () = // With the converter, things roundtrip either way for result in [defaultHandlingHasDashes; resNoDashes] do - let des = profileWithConverter.Deserialize result + let des = serdesWithConverter.Deserialize result test <@ value= des @> From 780299bfc041b61b744624186786ef039cf0b539 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 5 Jan 2022 14:28:22 +0000 Subject: [PATCH 06/14] Reinstate TypeSafeEnumConverterTests --- .../FsCodec.SystemTextJson.Tests.fsproj | 1 + .../TypeSafeEnumConverterTests.fs | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 tests/FsCodec.SystemTextJson.Tests/TypeSafeEnumConverterTests.fs diff --git a/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj b/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj index 0c867d8..b6ec900 100644 --- a/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj +++ b/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj @@ -27,6 +27,7 @@ + Fixtures.fs diff --git a/tests/FsCodec.SystemTextJson.Tests/TypeSafeEnumConverterTests.fs b/tests/FsCodec.SystemTextJson.Tests/TypeSafeEnumConverterTests.fs new file mode 100644 index 0000000..d0fd3a5 --- /dev/null +++ b/tests/FsCodec.SystemTextJson.Tests/TypeSafeEnumConverterTests.fs @@ -0,0 +1,49 @@ +module FsCodec.SystemTextJson.Tests.TypeSafeEnumConverterTests + +open FsCodec.SystemTextJson +open System +open System.Collections.Generic +open System.Text.Json +open Swensen.Unquote +open Xunit + +type Outcome = Joy | Pain | Misery + +let [] happy () = + test <@ box Joy = TypeSafeEnum.parseT (typeof) "Joy" @> + test <@ Joy = TypeSafeEnum.parse "Joy" @> + test <@ box Joy = TypeSafeEnum.parseT (typeof) "Joy" @> + test <@ None = TypeSafeEnum.tryParse "Wat" @> + raises <@ TypeSafeEnum.parse "Wat" @> + + let serdesWithOutcomeConverter = Options.Create(TypeSafeEnumConverter()) |> Serdes + test <@ Joy = serdesWithOutcomeConverter.Deserialize "\"Joy\"" @> + test <@ Some Joy = serdesWithOutcomeConverter.Deserialize "\"Joy\"" @> + raises <@ serdesWithOutcomeConverter.Deserialize "\"Confusion\"" @> + // Was a JsonException prior to V6 + let serdes = Options.Create() |> Serdes + raises <@ serdes.Deserialize "1" @> + +let [] sad () = + raises <@ TypeSafeEnum.tryParse "Wat" @> + raises <@ TypeSafeEnum.toString "Wat" @> + +[)>] +type OutcomeWithOther = Joy | Pain | Misery | Other +and OutcomeWithCatchAllConverter() = + inherit JsonIsomorphism() + override _.Pickle v = + TypeSafeEnum.toString v + + override _.UnPickle json = + json + |> TypeSafeEnum.tryParse + |> Option.defaultValue Other + +let [] fallBackExample () = + let serdes = Options.Create() |> Serdes + test <@ Joy = serdes.Deserialize "\"Joy\"" @> + test <@ Some Other = serdes.Deserialize "\"Wat\"" @> + test <@ Other = serdes.Deserialize "\"Wat\"" @> + raises <@ serdes.Deserialize "1" @> + test <@ Seq.forall (fun (x,y) -> x = y) <| Seq.zip [Joy; Other] (serdes.Deserialize "[\"Joy\", \"Wat\"]") @> From c151f0beebe5ad3b6c908c0d4ade223ac5b21234 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 5 Jan 2022 15:10:08 +0000 Subject: [PATCH 07/14] CHANGELOG.md --- CHANGELOG.md | 4 ++++ FsCodec.sln | 1 + 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7c9d2c..e1e0e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The `Unreleased` section name is replaced by the expected version of next releas ### Added ### Changed + +- `Serdes`: Changed `Serdes` to be stateful, requiring a specific set of `Options`/`Settings` that are always applied consistently [#70](https://github.com/jet/FsCodec/pull/70) +- `Serdes.DefaultSettings`: Updated [README.md ASP.NET integration advice](https://github.com/jet/FsCodec#aspnetstj) to reflect minor knock-on effect [#70](https://github.com/jet/FsCodec/pull/70) + ### Removed ### Fixed diff --git a/FsCodec.sln b/FsCodec.sln index b5c838e..fd34d10 100644 --- a/FsCodec.sln +++ b/FsCodec.sln @@ -18,6 +18,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".project", ".project", "{1D README.md = README.md SECURITY.md = SECURITY.md CHANGELOG.md = CHANGELOG.md + FsCodec.sln.DotSettings.user = FsCodec.sln.DotSettings.user EndProjectSection EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec", "src\FsCodec\FsCodec.fsproj", "{9D2A9566-9C80-4AF3-A487-76A9FE8CBE64}" From 67505268b8cd248d9f0fe09dc6f862fa94b692a4 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Tue, 4 Jan 2022 17:03:42 +0000 Subject: [PATCH 08/14] wip --- .../FsCodec.SystemTextJson.fsproj | 1 + .../TypeSafeEnumConverter.fs | 13 ++-- src/FsCodec.SystemTextJson/UnionConverter.fs | 61 +++++++++++++------ .../UnionOrTypeSafeEnumConverterFactory.fs | 28 +++++++++ .../FsCodec.SystemTextJson.Tests.fsproj | 1 + ...nionOrTypeSafeEnumConverterFactoryTests.fs | 27 ++++++++ 6 files changed, 102 insertions(+), 29 deletions(-) create mode 100644 src/FsCodec.SystemTextJson/UnionOrTypeSafeEnumConverterFactory.fs create mode 100644 tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs diff --git a/src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj b/src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj index 8582f9f..aebfd67 100644 --- a/src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj +++ b/src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj @@ -15,6 +15,7 @@ + diff --git a/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs b/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs index 477f4b5..c0539be 100755 --- a/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs +++ b/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs @@ -7,14 +7,11 @@ open System.Text.Json /// Utilities for working with DUs where none of the cases have a value module TypeSafeEnum = - let private _isTypeSafeEnum (t : Type) = - Union.isUnion t - && (Union.getUnion t).cases |> Seq.forall (fun case -> case.GetFields().Length = 0) - let isTypeSafeEnum : Type -> bool = memoize _isTypeSafeEnum + let isTypeSafeEnum : Type -> bool = function + | Union.TypeSafeEnum -> true + | Union.NotUnion | Union.Other -> false let tryParseT (t : Type) predicate = - if not (Union.isUnion t) then invalidArg "t" "Type must be a FSharpUnion." else - let u = Union.getUnion t u.cases |> Array.tryFindIndex (fun c -> predicate c.Name) @@ -31,9 +28,7 @@ module TypeSafeEnum = let parse<'T> (str : string) = parseT typeof<'T> str :?> 'T let toString<'t> (x : 't) = - if not (Union.isUnion (typeof<'t>)) then invalidArg "'t" "Type must be a FSharpUnion." else - - let u = Union.getUnion (typeof<'t>) + let u = Union.getUnion typeof<'t> let tag = u.tagReader (box x) // TOCONSIDER memoize and/or push into `Union` https://github.com/jet/FsCodec/pull/41#discussion_r394473137 u.cases.[tag].Name diff --git a/src/FsCodec.SystemTextJson/UnionConverter.fs b/src/FsCodec.SystemTextJson/UnionConverter.fs index 82895d0..bc53ce1 100755 --- a/src/FsCodec.SystemTextJson/UnionConverter.fs +++ b/src/FsCodec.SystemTextJson/UnionConverter.fs @@ -43,10 +43,9 @@ type private Union = module private Union = let isUnion : Type -> bool = memoize (fun t -> FSharpType.IsUnion(t, true)) - let getUnionCases = memoize (fun t -> FSharpType.GetUnionCases(t, true)) let private createUnion t = - let cases = getUnionCases t + let cases = FSharpType.GetUnionCases(t, true) { cases = cases tagReader = FSharpValue.PreComputeUnionTagReader(t, true) @@ -59,6 +58,14 @@ module private Union = } let getUnion : Type -> Union = memoize createUnion + /// Allows us to distinguish between Unions that have bodies and hence should UnionConverter + let (|NotUnion|TypeSafeEnum|Other|) (t : Type) = + if not (isUnion t) then NotUnion else + + let union = getUnion t + if union.cases |> Seq.forall (fun case -> case.GetFields().Length = 0) then TypeSafeEnum + else Other + /// Parallels F# behavior wrt how it generates a DU's underlying .NET Type let inline isInlinedIntoUnionItem (t : Type) = t = typeof @@ -68,27 +75,41 @@ module private Union = && (typedefof> = t.GetGenericTypeDefinition() || t.GetGenericTypeDefinition().IsValueType)) // Nullable - let typeHasJsonConverterAttribute_ (t : Type) = t.IsDefined(typeof(*, false*)) - let typeHasJsonConverterAttribute = memoize typeHasJsonConverterAttribute_ - let typeIsUnionWithConverterAttribute = memoize (fun (t : Type) -> isUnion t && typeHasJsonConverterAttribute_ t) - - let propTypeRequiresConstruction (propertyType : Type) = - not (isInlinedIntoUnionItem propertyType) - && not (typeHasJsonConverterAttribute propertyType) + let private typeHasJsonConverterAttribute_ (t : Type) = t.IsDefined(typeof(*, false*)) + let typeHasJsonConverterAttribute : Type -> bool = memoize typeHasJsonConverterAttribute_ /// Prepare arguments for the Case class ctor based on the kind of case and how F# maps that to a Type /// and/or whether we need to defer to System.Text.Json let mapTargetCaseArgs (element : JsonElement) (options : JsonSerializerOptions) (props : PropertyInfo[]) : obj [] = - match props with - | [| singleCaseArg |] when propTypeRequiresConstruction singleCaseArg.PropertyType -> - [| JsonSerializer.Deserialize(element, singleCaseArg.PropertyType, options) |] - | multipleFieldsInCustomCaseType -> - [| for fi in multipleFieldsInCustomCaseType -> - match element.TryGetProperty fi.Name with - | false, _ when fi.PropertyType.IsValueType -> Activator.CreateInstance fi.PropertyType - | false, _ -> null - | true, el when el.ValueKind = JsonValueKind.Null -> null - | true, el -> JsonSerializer.Deserialize(el, fi.PropertyType, options) |] + [| for fi in props -> + match element.TryGetProperty fi.Name with + | false, _ when props.Length = 1 && not fi.PropertyType.IsValueType && element.ValueKind = JsonValueKind.Object -> + JsonSerializer.Deserialize(element, fi.PropertyType, options) + | false, _ when props.Length = 1 && isInlinedIntoUnionItem fi.PropertyType -> + JsonSerializer.Deserialize(element, fi.PropertyType, options) + | false, _ -> + failwithf "NF %d %s %b" props.Length fi.Name (isInlinedIntoUnionItem fi.PropertyType) +// JsonSerializer.Deserialize(el, fi.PropertyType, options) + | true, el when props.Length <> 1 -> + JsonSerializer.Deserialize(el, fi.PropertyType, options) + | true, el when props.Length = 1 && typeHasJsonConverterAttribute fi.PropertyType -> + JsonSerializer.Deserialize(el, fi.PropertyType, options) + | true, el when props.Length = 1 -> + JsonSerializer.Deserialize(element, fi.PropertyType, options) + | true, el when props.Length = 1 && not (isInlinedIntoUnionItem fi.PropertyType) -> + JsonSerializer.Deserialize(el, fi.PropertyType, options) + | true, el when props.Length = 1 && isInlinedIntoUnionItem fi.PropertyType -> +// failwithf "NF2 %d %s %b" props.Length fi.Name (isInlinedIntoUnionItem fi.PropertyType) +// failwithf "NF2 %d %s %b" props.Length fi.Name fi.PropertyType +// failwithf "NF2 %d %s %b" props.Length fi.Name (isInlinedIntoUnionItem fi.PropertyType) + JsonSerializer.Deserialize(el, fi.PropertyType, options) +// | true, el when props.Length = 1 && isInlinedIntoUnionItem fi.PropertyType -> +// JsonSerializer.Deserialize(element, fi.PropertyType, options) +// failwithf "NF2 %d %s %b" props.Length fi.Name (isInlinedIntoUnionItem fi.PropertyType) + | true, el -> + failwithf "NF2 %d %s %b" props.Length fi.Name (isInlinedIntoUnionItem fi.PropertyType) |] +// let el = if props.Length = 1 && isInlinedIntoUnionItem fi.PropertyType then el else element +// JsonSerializer.Deserialize(element, fi.PropertyType, options) |] type UnionConverter<'T>() = inherit Serialization.JsonConverter<'T>() @@ -114,7 +135,7 @@ type UnionConverter<'T>() = for fieldInfo, fieldValue in Seq.zip fieldInfos fieldValues do if fieldValue <> null || options.DefaultIgnoreCondition <> Serialization.JsonIgnoreCondition.Always then let element = JsonSerializer.SerializeToElement(fieldValue, fieldInfo.PropertyType, options) - if fieldInfos.Length = 1 && element.ValueKind = JsonValueKind.Object && not (Union.typeIsUnionWithConverterAttribute fieldInfo.PropertyType) then + if fieldInfos.Length = 1 && element.ValueKind = JsonValueKind.Object && Union.isInlinedIntoUnionItem fieldInfo.PropertyType then // flatten the object properties into the same one as the discriminator for prop in element.EnumerateObject() do prop.WriteTo writer diff --git a/src/FsCodec.SystemTextJson/UnionOrTypeSafeEnumConverterFactory.fs b/src/FsCodec.SystemTextJson/UnionOrTypeSafeEnumConverterFactory.fs new file mode 100644 index 0000000..1c3e628 --- /dev/null +++ b/src/FsCodec.SystemTextJson/UnionOrTypeSafeEnumConverterFactory.fs @@ -0,0 +1,28 @@ +namespace FsCodec.SystemTextJson + +open System +open System.Linq.Expressions +open System.Text.Json.Serialization + +type internal ConverterActivator = delegate of unit -> JsonConverter + +type UnionOrTypeSafeEnumConverterFactory() = + inherit JsonConverterFactory() + + override _.CanConvert(t : Type) = + let res = Union.isUnion t + if Union.typeHasJsonConverterAttribute t then failwith "needs conjunction" + //&& not (Union.typeHasJsonConverterAttribute t) + res + + override _.CreateConverter(typ, _options) = + let constructor = + match typ with + | Union.NotUnion -> invalidOp (sprintf "%s is not a union type" typ.FullName) + | Union.TypeSafeEnum -> typedefof>.MakeGenericType(typ).GetConstructors() |> Array.head + | Union.Other _ -> typedefof>.MakeGenericType(typ).GetConstructors() |> Array.head + let newExpression = Expression.New(constructor) + let lambda = Expression.Lambda(typeof, newExpression) + + let activator = lambda.Compile() :?> ConverterActivator + activator.Invoke() diff --git a/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj b/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj index b6ec900..3d1cfbd 100644 --- a/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj +++ b/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj @@ -35,6 +35,7 @@ UnionConverterTests.fs + diff --git a/tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs b/tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs new file mode 100644 index 0000000..6e27081 --- /dev/null +++ b/tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs @@ -0,0 +1,27 @@ +module FsCodec.SystemTextJson.Tests.UnionOrTypeSafeEnumConverterFactoryTests + +open FsCodec.SystemTextJson +open Swensen.Unquote +open System.Text.Json + +type ATypeSafeEnum = A | B | C +type NotAUnion = { body : string } +type AUnion = D of value : string | E of ATypeSafeEnum | F +type Any = Tse of enum : ATypeSafeEnum | Not of NotAUnion | Union of AUnion + +let opts = Options.Create(converters=[| UnionOrTypeSafeEnumConverterFactory() |]) +let inline ser (x : 't) = JsonSerializer.Serialize<'t>(x, opts) +let inline des (x : string) : 't = JsonSerializer.Deserialize<'t>(x, opts) + +let [] ``Basic characteristics`` () = + test <@ "\"B\"" = ser B @> + test <@ "{\"body\":\"A\"}" = ser { body = "A" } @> + test <@ "{\"case\":\"D\",\"value\":\"A\"}" = ser (D "A") @> + test <@ "{\"case\":\"Tse\",\"enum\":\"B\"}" = ser (Tse B) @> + test <@ Tse B = des "{\"case\":\"Tse\",\"enum\":\"B\"}" @> + test <@ Not { body = "A" } = des "{\"case\":\"Not\",\"Item\":{\"body\":\"A\"}}" @> + +let [] ``auto-encodes Unions and non-unions`` (x: Any) = + let encoded = ser x + let decoded : Any = des encoded + test <@ decoded = x @> From 250af410c1e29f88539e39e02476cc2632b3c3ee Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Tue, 4 Jan 2022 17:26:05 +0000 Subject: [PATCH 09/14] Clean Converter selection --- src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs | 6 +++--- src/FsCodec.SystemTextJson/UnionConverter.fs | 7 ++----- .../UnionOrTypeSafeEnumConverterFactory.fs | 7 ++----- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs b/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs index c0539be..b21b878 100755 --- a/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs +++ b/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs @@ -7,9 +7,9 @@ open System.Text.Json /// Utilities for working with DUs where none of the cases have a value module TypeSafeEnum = - let isTypeSafeEnum : Type -> bool = function - | Union.TypeSafeEnum -> true - | Union.NotUnion | Union.Other -> false + let isTypeSafeEnum (typ : Type) = + Union.isUnion typ + && Union.hasOnlyNullaryCases typ let tryParseT (t : Type) predicate = let u = Union.getUnion t diff --git a/src/FsCodec.SystemTextJson/UnionConverter.fs b/src/FsCodec.SystemTextJson/UnionConverter.fs index bc53ce1..2fdbcad 100755 --- a/src/FsCodec.SystemTextJson/UnionConverter.fs +++ b/src/FsCodec.SystemTextJson/UnionConverter.fs @@ -59,12 +59,9 @@ module private Union = let getUnion : Type -> Union = memoize createUnion /// Allows us to distinguish between Unions that have bodies and hence should UnionConverter - let (|NotUnion|TypeSafeEnum|Other|) (t : Type) = - if not (isUnion t) then NotUnion else - + let hasOnlyNullaryCases (t : Type) = let union = getUnion t - if union.cases |> Seq.forall (fun case -> case.GetFields().Length = 0) then TypeSafeEnum - else Other + union.cases |> Seq.forall (fun case -> case.GetFields().Length = 0) /// Parallels F# behavior wrt how it generates a DU's underlying .NET Type let inline isInlinedIntoUnionItem (t : Type) = diff --git a/src/FsCodec.SystemTextJson/UnionOrTypeSafeEnumConverterFactory.fs b/src/FsCodec.SystemTextJson/UnionOrTypeSafeEnumConverterFactory.fs index 1c3e628..6f140f9 100644 --- a/src/FsCodec.SystemTextJson/UnionOrTypeSafeEnumConverterFactory.fs +++ b/src/FsCodec.SystemTextJson/UnionOrTypeSafeEnumConverterFactory.fs @@ -16,11 +16,8 @@ type UnionOrTypeSafeEnumConverterFactory() = res override _.CreateConverter(typ, _options) = - let constructor = - match typ with - | Union.NotUnion -> invalidOp (sprintf "%s is not a union type" typ.FullName) - | Union.TypeSafeEnum -> typedefof>.MakeGenericType(typ).GetConstructors() |> Array.head - | Union.Other _ -> typedefof>.MakeGenericType(typ).GetConstructors() |> Array.head + let openConverterType = if Union.hasOnlyNullaryCases typ then typedefof> else typedefof> + let constructor = openConverterType.MakeGenericType(typ).GetConstructors() |> Array.head let newExpression = Expression.New(constructor) let lambda = Expression.Lambda(typeof, newExpression) From b85dca658eeaceb1487319471092fb852cc0a6bb Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 5 Jan 2022 11:33:29 +0000 Subject: [PATCH 10/14] Rewrite --- .../TypeSafeEnumConverter.fs | 4 +- src/FsCodec.SystemTextJson/UnionConverter.fs | 96 +++++-------------- .../UnionOrTypeSafeEnumConverterFactory.fs | 5 +- .../UnionConverterTests.fs | 4 + ...nionOrTypeSafeEnumConverterFactoryTests.fs | 4 +- 5 files changed, 34 insertions(+), 79 deletions(-) diff --git a/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs b/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs index b21b878..bae51df 100755 --- a/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs +++ b/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs @@ -12,7 +12,7 @@ module TypeSafeEnum = && Union.hasOnlyNullaryCases typ let tryParseT (t : Type) predicate = - let u = Union.getUnion t + let u = Union.getInfo t u.cases |> Array.tryFindIndex (fun c -> predicate c.Name) |> Option.map (fun tag -> u.caseConstructor.[tag] [||]) @@ -28,7 +28,7 @@ module TypeSafeEnum = let parse<'T> (str : string) = parseT typeof<'T> str :?> 'T let toString<'t> (x : 't) = - let u = Union.getUnion typeof<'t> + let u = Union.getInfo typeof<'t> let tag = u.tagReader (box x) // TOCONSIDER memoize and/or push into `Union` https://github.com/jet/FsCodec/pull/41#discussion_r394473137 u.cases.[tag].Name diff --git a/src/FsCodec.SystemTextJson/UnionConverter.fs b/src/FsCodec.SystemTextJson/UnionConverter.fs index 2fdbcad..4aac49b 100755 --- a/src/FsCodec.SystemTextJson/UnionConverter.fs +++ b/src/FsCodec.SystemTextJson/UnionConverter.fs @@ -2,17 +2,15 @@ open FSharp.Reflection open System -open System.Reflection open System.Text.Json type IUnionConverterOptions = abstract member Discriminator : string with get abstract member CatchAllCase : string option with get -/// Use this attribute in combination with a JsonConverter/UnionConverter attribute to specify -/// your own name for a discriminator and/or a catch-all case for a specific discriminated union. -/// If this attribute is set, its values take precedence over the values set on the converter via its constructor. -/// Example: [>); JsonUnionConverterOptions("type")>] +/// Use this attribute in combination with a JsonConverter / UnionConverter attribute to specify +/// your own name for a discriminator and/or a catch-all case for a specific discriminated union. +/// [JsonConverter typeof < UnionConverter < T > >); JsonUnionConverterOptions("type") >] [] type JsonUnionConverterOptionsAttribute(discriminator : string) = inherit Attribute() @@ -21,93 +19,42 @@ type JsonUnionConverterOptionsAttribute(discriminator : string) = member _.Discriminator = discriminator member x.CatchAllCase = Option.ofObj x.CatchAllCase -type UnionConverterOptions = - { - discriminator : string - catchAllCase : string option - } +type private UnionConverterOptions = + { discriminator : string + catchAllCase : string option } interface IUnionConverterOptions with member x.Discriminator = x.discriminator member x.CatchAllCase = x.catchAllCase [] type private Union = - { - cases : UnionCaseInfo[] + { cases : UnionCaseInfo[] tagReader : obj -> int fieldReader : (obj -> obj[])[] caseConstructor : (obj[] -> obj)[] - options : IUnionConverterOptions option - } + options : IUnionConverterOptions option } module private Union = let isUnion : Type -> bool = memoize (fun t -> FSharpType.IsUnion(t, true)) - let private createUnion t = + let private createInfo t = let cases = FSharpType.GetUnionCases(t, true) - { - cases = cases + { cases = cases tagReader = FSharpValue.PreComputeUnionTagReader(t, true) fieldReader = cases |> Array.map (fun c -> FSharpValue.PreComputeUnionReader(c, true)) caseConstructor = cases |> Array.map (fun c -> FSharpValue.PreComputeUnionConstructor(c, true)) options = t.GetCustomAttributes(typeof, false) |> Array.tryHead // AttributeUsage(AllowMultiple = false) - |> Option.map (fun a -> a :?> IUnionConverterOptions) - } - let getUnion : Type -> Union = memoize createUnion + |> Option.map (fun a -> a :?> IUnionConverterOptions) } + let getInfo : Type -> Union = memoize createInfo /// Allows us to distinguish between Unions that have bodies and hence should UnionConverter let hasOnlyNullaryCases (t : Type) = - let union = getUnion t + let union = getInfo t union.cases |> Seq.forall (fun case -> case.GetFields().Length = 0) - /// Parallels F# behavior wrt how it generates a DU's underlying .NET Type - let inline isInlinedIntoUnionItem (t : Type) = - t = typeof - || (t.IsValueType && t <> typeof) - || t.IsArray - || (t.IsGenericType - && (typedefof> = t.GetGenericTypeDefinition() - || t.GetGenericTypeDefinition().IsValueType)) // Nullable - - let private typeHasJsonConverterAttribute_ (t : Type) = t.IsDefined(typeof(*, false*)) - let typeHasJsonConverterAttribute : Type -> bool = memoize typeHasJsonConverterAttribute_ - - /// Prepare arguments for the Case class ctor based on the kind of case and how F# maps that to a Type - /// and/or whether we need to defer to System.Text.Json - let mapTargetCaseArgs (element : JsonElement) (options : JsonSerializerOptions) (props : PropertyInfo[]) : obj [] = - [| for fi in props -> - match element.TryGetProperty fi.Name with - | false, _ when props.Length = 1 && not fi.PropertyType.IsValueType && element.ValueKind = JsonValueKind.Object -> - JsonSerializer.Deserialize(element, fi.PropertyType, options) - | false, _ when props.Length = 1 && isInlinedIntoUnionItem fi.PropertyType -> - JsonSerializer.Deserialize(element, fi.PropertyType, options) - | false, _ -> - failwithf "NF %d %s %b" props.Length fi.Name (isInlinedIntoUnionItem fi.PropertyType) -// JsonSerializer.Deserialize(el, fi.PropertyType, options) - | true, el when props.Length <> 1 -> - JsonSerializer.Deserialize(el, fi.PropertyType, options) - | true, el when props.Length = 1 && typeHasJsonConverterAttribute fi.PropertyType -> - JsonSerializer.Deserialize(el, fi.PropertyType, options) - | true, el when props.Length = 1 -> - JsonSerializer.Deserialize(element, fi.PropertyType, options) - | true, el when props.Length = 1 && not (isInlinedIntoUnionItem fi.PropertyType) -> - JsonSerializer.Deserialize(el, fi.PropertyType, options) - | true, el when props.Length = 1 && isInlinedIntoUnionItem fi.PropertyType -> -// failwithf "NF2 %d %s %b" props.Length fi.Name (isInlinedIntoUnionItem fi.PropertyType) -// failwithf "NF2 %d %s %b" props.Length fi.Name fi.PropertyType -// failwithf "NF2 %d %s %b" props.Length fi.Name (isInlinedIntoUnionItem fi.PropertyType) - JsonSerializer.Deserialize(el, fi.PropertyType, options) -// | true, el when props.Length = 1 && isInlinedIntoUnionItem fi.PropertyType -> -// JsonSerializer.Deserialize(element, fi.PropertyType, options) -// failwithf "NF2 %d %s %b" props.Length fi.Name (isInlinedIntoUnionItem fi.PropertyType) - | true, el -> - failwithf "NF2 %d %s %b" props.Length fi.Name (isInlinedIntoUnionItem fi.PropertyType) |] -// let el = if props.Length = 1 && isInlinedIntoUnionItem fi.PropertyType then el else element -// JsonSerializer.Deserialize(element, fi.PropertyType, options) |] - type UnionConverter<'T>() = inherit Serialization.JsonConverter<'T>() @@ -119,7 +66,7 @@ type UnionConverter<'T>() = override _.Write(writer, value, options) = let value = box value - let union = Union.getUnion typeof<'T> + let union = Union.getInfo typeof<'T> let unionOptions = getOptions union let tag = union.tagReader value let case = union.cases.[tag] @@ -132,8 +79,8 @@ type UnionConverter<'T>() = for fieldInfo, fieldValue in Seq.zip fieldInfos fieldValues do if fieldValue <> null || options.DefaultIgnoreCondition <> Serialization.JsonIgnoreCondition.Always then let element = JsonSerializer.SerializeToElement(fieldValue, fieldInfo.PropertyType, options) - if fieldInfos.Length = 1 && element.ValueKind = JsonValueKind.Object && Union.isInlinedIntoUnionItem fieldInfo.PropertyType then - // flatten the object properties into the same one as the discriminator + if fieldInfos.Length = 1 && FSharpType.IsRecord(fieldInfo.PropertyType, true) then + // flatten the record properties into the same JSON object as the discriminator for prop in element.EnumerateObject() do prop.WriteTo writer else @@ -145,7 +92,7 @@ type UnionConverter<'T>() = if reader.TokenType <> JsonTokenType.StartObject then sprintf "Unexpected token when reading Union: %O" reader.TokenType |> JsonException |> raise use document = JsonDocument.ParseValue &reader - let union = Union.getUnion typeof<'T> + let union = Union.getInfo typeof<'T> let unionOptions = getOptions union let element = document.RootElement @@ -165,4 +112,11 @@ type UnionConverter<'T>() = | Some foundIndex -> foundIndex let targetCaseFields, targetCaseCtor = union.cases.[targetCaseIndex].GetFields(), union.caseConstructor.[targetCaseIndex] - targetCaseCtor (Union.mapTargetCaseArgs element options targetCaseFields) :?> 'T + let ctorArgs = + [| for fieldInfo in targetCaseFields -> + let t = fieldInfo.PropertyType + let targetEl = + if targetCaseFields.Length = 1 && (t = typeof || FSharpType.IsRecord(t, true)) then element + else let _found, el = element.TryGetProperty fieldInfo.Name in el + JsonSerializer.Deserialize(targetEl, t, options) |] + targetCaseCtor ctorArgs :?> 'T diff --git a/src/FsCodec.SystemTextJson/UnionOrTypeSafeEnumConverterFactory.fs b/src/FsCodec.SystemTextJson/UnionOrTypeSafeEnumConverterFactory.fs index 6f140f9..8c31c34 100644 --- a/src/FsCodec.SystemTextJson/UnionOrTypeSafeEnumConverterFactory.fs +++ b/src/FsCodec.SystemTextJson/UnionOrTypeSafeEnumConverterFactory.fs @@ -10,10 +10,7 @@ type UnionOrTypeSafeEnumConverterFactory() = inherit JsonConverterFactory() override _.CanConvert(t : Type) = - let res = Union.isUnion t - if Union.typeHasJsonConverterAttribute t then failwith "needs conjunction" - //&& not (Union.typeHasJsonConverterAttribute t) - res + Union.isUnion t override _.CreateConverter(typ, _options) = let openConverterType = if Union.hasOnlyNullaryCases typ then typedefof> else typedefof> diff --git a/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs b/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs index c82b334..0f67961 100644 --- a/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs +++ b/tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs @@ -487,6 +487,7 @@ module ``Struct discriminated unions`` = | CaseAV of av : TestRecordPayloadStruct | CaseB | CaseC of string + | CaseC2 of c2: int | CaseD of d : string | CaseE of e : string * int | CaseF of f : string * fb : int @@ -511,6 +512,9 @@ module ``Struct discriminated unions`` = let c = CaseC "hi" test <@ """{"case":"CaseC","Item":"hi"}""" = serialize c @> + let c2 = CaseC2 2 + test <@ """{"case":"CaseC2","c2":2}""" = serialize c2 @> + let d = CaseD "hi" test <@ """{"case":"CaseD","d":"hi"}""" = serialize d @> diff --git a/tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs b/tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs index 6e27081..8a98c4b 100644 --- a/tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs +++ b/tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs @@ -19,9 +19,9 @@ let [] ``Basic characteristics`` () = test <@ "{\"case\":\"D\",\"value\":\"A\"}" = ser (D "A") @> test <@ "{\"case\":\"Tse\",\"enum\":\"B\"}" = ser (Tse B) @> test <@ Tse B = des "{\"case\":\"Tse\",\"enum\":\"B\"}" @> - test <@ Not { body = "A" } = des "{\"case\":\"Not\",\"Item\":{\"body\":\"A\"}}" @> + test <@ Not { body = "A" } = des "{\"case\":\"Not\",\"body\":\"A\"}" @> -let [] ``auto-encodes Unions and non-unions`` (x: Any) = +let [] ``auto-encodes Unions and non-unions`` (x : Any) = let encoded = ser x let decoded : Any = des encoded test <@ decoded = x @> From 9b69a7c59945c97feb07e7343812ae22142cdf9e Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 5 Jan 2022 13:45:58 +0000 Subject: [PATCH 11/14] Make Serdes stateful --- .../FsCodec.SystemTextJson.fsproj | 2 +- src/FsCodec.SystemTextJson/Options.fs | 6 +++++- ...nionOrTypeSafeEnumConverterFactoryTests.fs | 21 ++++++++----------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj b/src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj index aebfd67..f8682c4 100644 --- a/src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj +++ b/src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj @@ -11,11 +11,11 @@ + - diff --git a/src/FsCodec.SystemTextJson/Options.fs b/src/FsCodec.SystemTextJson/Options.fs index 35c43c8..8533e24 100755 --- a/src/FsCodec.SystemTextJson/Options.fs +++ b/src/FsCodec.SystemTextJson/Options.fs @@ -54,7 +54,11 @@ type Options private () = [] ?autoUnion : bool) = Options.CreateDefault( - converters = converters, + converters = + ( if autoUnion = Some true then + let converter : JsonConverter array = [| UnionOrTypeSafeEnumConverterFactory() |] + if converters = null then converter else Array.append converters converter + else converters), ?ignoreNulls = ignoreNulls, ?indent = indent, ?camelCase = camelCase, diff --git a/tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs b/tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs index 8a98c4b..c3a770f 100644 --- a/tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs +++ b/tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs @@ -2,26 +2,23 @@ module FsCodec.SystemTextJson.Tests.UnionOrTypeSafeEnumConverterFactoryTests open FsCodec.SystemTextJson open Swensen.Unquote -open System.Text.Json type ATypeSafeEnum = A | B | C type NotAUnion = { body : string } type AUnion = D of value : string | E of ATypeSafeEnum | F type Any = Tse of enum : ATypeSafeEnum | Not of NotAUnion | Union of AUnion -let opts = Options.Create(converters=[| UnionOrTypeSafeEnumConverterFactory() |]) -let inline ser (x : 't) = JsonSerializer.Serialize<'t>(x, opts) -let inline des (x : string) : 't = JsonSerializer.Deserialize<'t>(x, opts) +let serdes = Options.Create(autoUnion = true) |> Serdes let [] ``Basic characteristics`` () = - test <@ "\"B\"" = ser B @> - test <@ "{\"body\":\"A\"}" = ser { body = "A" } @> - test <@ "{\"case\":\"D\",\"value\":\"A\"}" = ser (D "A") @> - test <@ "{\"case\":\"Tse\",\"enum\":\"B\"}" = ser (Tse B) @> - test <@ Tse B = des "{\"case\":\"Tse\",\"enum\":\"B\"}" @> - test <@ Not { body = "A" } = des "{\"case\":\"Not\",\"body\":\"A\"}" @> + test <@ "\"B\"" = serdes.Serialize B @> + test <@ "{\"body\":\"A\"}" = serdes.Serialize { body = "A" } @> + test <@ "{\"case\":\"D\",\"value\":\"A\"}" = serdes.Serialize (D "A") @> + test <@ "{\"case\":\"Tse\",\"enum\":\"B\"}" = serdes.Serialize (Tse B) @> + test <@ Tse B = serdes.Deserialize "{\"case\":\"Tse\",\"enum\":\"B\"}" @> + test <@ Not { body = "A" } = serdes.Deserialize "{\"case\":\"Not\",\"body\":\"A\"}" @> let [] ``auto-encodes Unions and non-unions`` (x : Any) = - let encoded = ser x - let decoded : Any = des encoded + let encoded = serdes.Serialize x + let decoded : Any = serdes.Deserialize encoded test <@ decoded = x @> From 9575dc9ae561ea798b7cf03b9378555ace576c5c Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 5 Jan 2022 13:47:50 +0000 Subject: [PATCH 12/14] -> AutoUnionTests --- ...OrTypeSafeEnumConverterFactoryTests.fs => AutoUnionTests.fs} | 2 +- .../FsCodec.SystemTextJson.Tests.fsproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename tests/FsCodec.SystemTextJson.Tests/{UnionOrTypeSafeEnumConverterFactoryTests.fs => AutoUnionTests.fs} (92%) diff --git a/tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs b/tests/FsCodec.SystemTextJson.Tests/AutoUnionTests.fs similarity index 92% rename from tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs rename to tests/FsCodec.SystemTextJson.Tests/AutoUnionTests.fs index c3a770f..84b2e31 100644 --- a/tests/FsCodec.SystemTextJson.Tests/UnionOrTypeSafeEnumConverterFactoryTests.fs +++ b/tests/FsCodec.SystemTextJson.Tests/AutoUnionTests.fs @@ -1,4 +1,4 @@ -module FsCodec.SystemTextJson.Tests.UnionOrTypeSafeEnumConverterFactoryTests +module FsCodec.SystemTextJson.Tests.AutoUnionTests open FsCodec.SystemTextJson open Swensen.Unquote diff --git a/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj b/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj index 3d1cfbd..4c7bdac 100644 --- a/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj +++ b/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj @@ -35,7 +35,7 @@ UnionConverterTests.fs - + From a6335e0a40e81fe237ef0db962dc4ffce019eb1e Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 5 Jan 2022 15:03:12 +0000 Subject: [PATCH 13/14] Add example to README --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 985d5e9..f26c575 100644 --- a/README.md +++ b/README.md @@ -150,17 +150,17 @@ This adds all the converters used by the `serdes` serialization/deserialization ## ASP.NET Core with `System.Text.Json` -The equivalent for the native `System.Text.Json` looks like this: +The equivalent for the native `System.Text.Json`, as v6, thanks [to the great work of the .NET team](https://github.com/dotnet/runtime/pull/55108), is presently a no-op. - let serdes = FsCodec.SystemTextJson.Options.Create() |> FsCodec.SystemTextJson.Serdes +The following illustrates how opt into [`autoUnion` mode](https://github.com/jet/FsCodec/blob/master/tests/FsCodec.SystemTextJson.Tests/AutoUnionTests.fs): + + let serdes = FsCodec.SystemTextJson.Options.Create(autoUnion = true) |> FsCodec.SystemTextJson.Serdes services.AddMvc(fun options -> ... ).AddJsonOptions(fun options -> serdes.Options.Converters |> Seq.iter options.JsonSerializerOptions.Converters.Add ) |> ignore -_As of `System.Text.Json` v6, thanks [to the great work of the .NET team](https://github.com/dotnet/runtime/pull/55108), the above is presently a no-op._ - # Examples: `FsCodec.(Newtonsoft|SystemText)Json` There's a test playground in [tests/FsCodec.NewtonsoftJson.Tests/Examples.fsx](tests/FsCodec.NewtonsoftJson.Tests/Examples.fsx). It's highly recommended to experiment with conversions using FSI. (Also, PRs adding examples are much appreciated...) From 586154795e9cd821c48a58e0155c8d67de3bfd5b Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 5 Jan 2022 15:25:51 +0000 Subject: [PATCH 14/14] Documentation/changelog --- CHANGELOG.md | 4 ++++ README.md | 12 +++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e0e9f..e34fa84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ The `Unreleased` section name is replaced by the expected version of next releas ## [Unreleased] ### Added + +- `SystemTextJson.UnionOrTypeSafeEnumConverterFactory`: Global converter that automatically applies a `TypeSafeEnumConverter` to all Discriminated Unions that support it, and `UnionConverter` to all others [#69](https://github.com/jet/FsCodec/pull/69) +- `SystemTextJson.Options(autoUnion = true)`: Automated wireup of `UnionOrTypeSafeEnumConverterFactory` [#69](https://github.com/jet/FsCodec/pull/69) + ### Changed - `Serdes`: Changed `Serdes` to be stateful, requiring a specific set of `Options`/`Settings` that are always applied consistently [#70](https://github.com/jet/FsCodec/pull/70) diff --git a/README.md b/README.md index f26c575..8cf3c78 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,11 @@ The respective concrete Codec packages include relevant `Converter`/`JsonConvert - [`OptionConverter`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/OptionConverter.fs#L7) represents F#'s `Option<'t>` as a value or `null`; included in the standard `Settings.Create` profile. - [`VerbatimUtf8JsonConverter`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/VerbatimUtf8JsonConverter.fs#L7) captures/renders known valid UTF8 JSON data into a `byte[]` without decomposing it into an object model (not typically relevant for application level code, used in `Equinox.Cosmos` versions prior to `3.0`). - + +### `FsCodec.SystemTextJson`-specific low level converters + +- `UnionOrTypeSafeEnumConverterFactory`: Global converter that automatically applies a `TypeSafeEnumConverter` to all Discriminated Unions that support it, and `UnionConverter` to all others. See [this `System.Text.Json` issue](https://github.com/dotnet/runtime/issues/55744) for background information as to the reasoning behind and tradeoffs involved in applying such a policy. + ## `FsCodec.NewtonsoftJson.Settings` [`FsCodec.NewtonsoftJson.Settings`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/Settings.fs#L8) provides a clean syntax for building a `Newtonsoft.Json.JsonSerializerSettings` with which to define a serialization contract profile for interoperability purposes. Methods: @@ -110,7 +114,9 @@ The respective concrete Codec packages include relevant `Converter`/`JsonConvert [`FsCodec.SystemTextJson.Options`](https://github.com/jet/FsCodec/blob/stj/src/FsCodec.SystemTextJson/Options.fs#L8) provides a clean syntax for building a `System.Text.Json.Serialization.JsonSerializerOptions` as per `FsCodec.NewtonsoftJson.Settings`, above. Methods: - `CreateDefault`: equivalent to generating a `new JsonSerializerSettings()` without any overrides of any kind - `Create`: as `CreateDefault` with the following difference: - - Inhibits the HTML-safe escaping that `System.Text.Json` provides as a default by overriding `Encoder` with `System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping` + - By default, inhibits the HTML-safe escaping that `System.Text.Json` provides as a default by overriding `Encoder` with `System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping` + - `(camelCase = true)`: opts into camel case conversion for `PascalCased` properties and `Dictionary` keys + - `(autoUnion = true)`: triggers inclusion of a `UnionOrTypeSafeEnumConverterFactory`, enabling F# Discriminated Unions to be converted in an opinionated manner. See [`AutoUnionTests.fs`](https://github.com/jet/FsCodec/blob/master/tests/FsCodec.SystemTextJson.Tests/AutoUnionTests.fs) for examples ## `Serdes` @@ -152,7 +158,7 @@ This adds all the converters used by the `serdes` serialization/deserialization The equivalent for the native `System.Text.Json`, as v6, thanks [to the great work of the .NET team](https://github.com/dotnet/runtime/pull/55108), is presently a no-op. -The following illustrates how opt into [`autoUnion` mode](https://github.com/jet/FsCodec/blob/master/tests/FsCodec.SystemTextJson.Tests/AutoUnionTests.fs): +The following illustrates how opt into [`autoUnion` mode](https://github.com/jet/FsCodec/blob/master/tests/FsCodec.SystemTextJson.Tests/AutoUnionTests.fs) for the rendering of View Models by ASP.NET: let serdes = FsCodec.SystemTextJson.Options.Create(autoUnion = true) |> FsCodec.SystemTextJson.Serdes