diff --git a/FsCodec.sln b/FsCodec.sln index 6947a84..cd70009 100644 --- a/FsCodec.sln +++ b/FsCodec.sln @@ -24,6 +24,10 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec", "src\FsCodec\FsCo EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec.Tests", "tests\FsCodec.Tests\FsCodec.Tests.fsproj", "{0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec.SystemTextJson", "src\FsCodec.SystemTextJson\FsCodec.SystemTextJson.fsproj", "{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec.SystemTextJson.Tests", "tests\FsCodec.SystemTextJson.Tests\FsCodec.SystemTextJson.Tests.fsproj", "{5C57C6D6-59AB-426F-9999-FDB90864545E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -82,6 +86,30 @@ Global {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Release|x64.Build.0 = Release|Any CPU {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Release|x86.ActiveCfg = Release|Any CPU {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Release|x86.Build.0 = Release|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|x64.ActiveCfg = Debug|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|x64.Build.0 = Debug|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|x86.ActiveCfg = Debug|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|x86.Build.0 = Debug|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|Any CPU.Build.0 = Release|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|x64.ActiveCfg = Release|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|x64.Build.0 = Release|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|x86.ActiveCfg = Release|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|x86.Build.0 = Release|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|x64.ActiveCfg = Debug|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|x64.Build.0 = Debug|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|x86.ActiveCfg = Debug|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|x86.Build.0 = Debug|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|Any CPU.Build.0 = Release|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|x64.ActiveCfg = Release|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|x64.Build.0 = Release|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|x86.ActiveCfg = Release|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/build.proj b/build.proj index 221ff26..c2ee8b8 100644 --- a/build.proj +++ b/build.proj @@ -14,11 +14,13 @@ + + diff --git a/src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj b/src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj new file mode 100644 index 0000000..a4c09a0 --- /dev/null +++ b/src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj @@ -0,0 +1,33 @@ + + + + netstandard2.1 + 5 + false + true + true + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/FsCodec.SystemTextJson/JsonRecordConverter.fs b/src/FsCodec.SystemTextJson/JsonRecordConverter.fs index f0c40b7..0b67d39 100644 --- a/src/FsCodec.SystemTextJson/JsonRecordConverter.fs +++ b/src/FsCodec.SystemTextJson/JsonRecordConverter.fs @@ -1,13 +1,11 @@ namespace FsCodec.SystemTextJson.Serialization -open Equinox.Core +open FSharp.Reflection open System open System.Collections.Generic -open System.Linq open System.Linq.Expressions open System.Text.Json open System.Text.Json.Serialization -open FSharp.Reflection type JsonRecordConverterActivator = delegate of JsonSerializerOptions -> JsonConverter @@ -55,7 +53,7 @@ type JsonRecordConverter<'T> (options: JsonSerializerOptions) = |> Array.tryHead |> Option.map (fun attr -> (attr :?> JsonPropertyNameAttribute).Name) |> Option.defaultWith (fun () -> - if options.PropertyNamingPolicy |> isNull + if options.PropertyNamingPolicy |> isNull then f.Name else options.PropertyNamingPolicy.ConvertName f.Name) @@ -92,7 +90,7 @@ type JsonRecordConverter<'T> (options: JsonSerializerOptions) = |> Array.map KeyValuePair |> (fun kvp -> kvp.ToDictionary((fun item -> item.Key), (fun item -> item.Value), StringComparer.OrdinalIgnoreCase)) #endif - + let tryGetFieldByName name = match fieldsByName.TryGetValue(name) with | true, field -> Some field @@ -107,7 +105,7 @@ type JsonRecordConverter<'T> (options: JsonSerializerOptions) = reader.ValidateTokenType(JsonTokenType.PropertyName) match tryGetFieldByName <| reader.GetString() with - | Some field -> + | Some field -> fields.[field.index] <- match field.converter with | Some converter -> @@ -115,7 +113,7 @@ type JsonRecordConverter<'T> (options: JsonSerializerOptions) = converter.Read(&reader, field.fieldType, options) | None -> JsonSerializer.Deserialize(&reader, field.fieldType, options) - | _ -> + | _ -> reader.Skip() constructor fields :?> 'T diff --git a/src/FsCodec.SystemTextJson/Options.fs b/src/FsCodec.SystemTextJson/Options.fs old mode 100644 new mode 100755 index 6867c76..2c295ac --- a/src/FsCodec.SystemTextJson/Options.fs +++ b/src/FsCodec.SystemTextJson/Options.fs @@ -1,14 +1,52 @@ -namespace FsCodec.SystemTextJson.Serialization +namespace FsCodec.SystemTextJson +open FsCodec.SystemTextJson.Serialization +open System +open System.Runtime.InteropServices open System.Text.Json +open System.Text.Json.Serialization -[] -module JsonSerializerOptionExtensions = - type JsonSerializerOptions with - static member Create() = - let options = JsonSerializerOptions() - options.Converters.Add(new JsonRecordConverter()) - options +type Options private () = -module JsonSerializer = - let defaultOptions = JsonSerializerOptions.Create() + static let defaultConverters : JsonConverterFactory[] = [| JsonOptionConverter(); JsonRecordConverter() |] + + /// Creates a default set of serializer options used by Json serialization. When used with no args, same as `JsonSerializerOptions()` + static member CreateDefault + ( [] converters : JsonConverterFactory[], + /// Use multi-line, indented formatting when serializing JSON; defaults to false. + [] ?indent : bool, + /// Render idiomatic camelCase for PascalCase items by using `PropertyNamingPolicy = CamelCase`. Defaults to false. + [] ?camelCase : bool, + /// Ignore null values in input data; defaults to false. + [] ?ignoreNulls : bool) = + + let indent = defaultArg indent false + let camelCase = defaultArg camelCase false + let ignoreNulls = defaultArg ignoreNulls false + let options = JsonSerializerOptions() + if converters <> null then converters |> Array.iter options.Converters.Add + if indent then options.WriteIndented <- true + if camelCase then options.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase; options.DictionaryKeyPolicy <- JsonNamingPolicy.CamelCase + if ignoreNulls then options.IgnoreNullValues <- true + options + + /// Opinionated helper that creates serializer settings that provide good defaults for F# + /// - Always prepends `[JsonOptionConverter(); JsonRecordConverter()]` to any converters supplied + /// - no camel case conversion - assumption is you'll use records with camelCased names + /// Everything else is as per CreateDefault:- i.e. emit nulls instead of omitting fields, no indenting, no camelCase conversion + static member Create + ( /// List of converters to apply. Implicit [JsonOptionConverter(); JsonRecordConverter()] will be prepended and/or be used as a default + [] converters : JsonConverterFactory[], + /// Use multi-line, indented formatting when serializing JSON; defaults to false. + [] ?indent : bool, + /// Render idiomatic camelCase for PascalCase items by using `PropertyNamingPolicy = CamelCase`. + /// Defaults to false on basis that you'll use record and tuple field names that are camelCase (but thus not `CLSCompliant`). + [] ?camelCase : bool, + /// Ignore null values in input data; defaults to `false`. + [] ?ignoreNulls : bool) = + + Options.CreateDefault( + converters = (match converters with null | [||] -> defaultConverters | xs -> Array.append defaultConverters xs), + ?ignoreNulls = ignoreNulls, + ?indent = indent, + ?camelCase = camelCase) diff --git a/src/FsCodec.SystemTextJson/Serdes.fs b/src/FsCodec.SystemTextJson/Serdes.fs new file mode 100755 index 0000000..a980509 --- /dev/null +++ b/src/FsCodec.SystemTextJson/Serdes.fs @@ -0,0 +1,36 @@ +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 () = + + static let defaultOptions = lazy Options.Create() + static let indentOptions = lazy Options.Create(indent = true) + + /// 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 + JsonSerializer.Serialize(value, options) + + /// Serializes given value to a JSON string with custom options + static member Serialize<'T> + ( /// Value to serialize. + value : 'T, + /// Options to use (use other overload to use Options.Create() profile) + options : JsonSerializerOptions) : string = + JsonSerializer.Serialize(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 + JsonSerializer.Deserialize<'T>(json, settings) diff --git a/src/FsCodec.SystemTextJson/Utf8JsonReaderExtensions.fs b/src/FsCodec.SystemTextJson/Utf8JsonReaderExtensions.fs index 9e29bb5..4c5d9c8 100644 --- a/src/FsCodec.SystemTextJson/Utf8JsonReaderExtensions.fs +++ b/src/FsCodec.SystemTextJson/Utf8JsonReaderExtensions.fs @@ -1,7 +1,7 @@ namespace FsCodec.SystemTextJson.Serialization -open System.Text.Json open System.Runtime.CompilerServices +open System.Text.Json [] type Utf8JsonReaderExtension = @@ -12,11 +12,11 @@ type Utf8JsonReaderExtension = |> JsonException |> raise - [] - static member ValidatePropertyName(reader: Utf8JsonReader, expectedPropertyName: string) = - reader.ValidateTokenType(JsonTokenType.PropertyName) - - if not <| reader.ValueTextEquals expectedPropertyName then - sprintf "Expected a property named '%s', but encountered property with name '%s'." expectedPropertyName (reader.GetString()) - |> JsonException - |> raise +// [] +// static member ValidatePropertyName(reader: Utf8JsonReader, expectedPropertyName: string) = +// reader.ValidateTokenType(JsonTokenType.PropertyName) +// +// if not <| reader.ValueTextEquals expectedPropertyName then +// sprintf "Expected a property named '%s', but encountered property with name '%s'." expectedPropertyName (reader.GetString()) +// |> JsonException +// |> raise diff --git a/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj b/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj new file mode 100644 index 0000000..dd154df --- /dev/null +++ b/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj @@ -0,0 +1,24 @@ + + + + netcoreapp3.1 + 5 + false + + + + + + + + + + + + + + + + + + diff --git a/tests/FsCodec.SystemTextJson.Tests/SerdesTests.fs b/tests/FsCodec.SystemTextJson.Tests/SerdesTests.fs new file mode 100644 index 0000000..72e5718 --- /dev/null +++ b/tests/FsCodec.SystemTextJson.Tests/SerdesTests.fs @@ -0,0 +1,51 @@ +module FsCodec.SystemTextJson.Tests.SerdesTests + +open FsCodec.SystemTextJson +open Swensen.Unquote +open Xunit + +type Record = { a : int } + +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 earranted *) +module StjCharacterization = + let ootbOptions = Options.CreateDefault() + + let [] ``OOTB STJ records`` () = + let value = { a = 1 } + let ser = Serdes.Serialize(value, ootbOptions) + test <@ ser = """{"a":1}""" @> + + let res = try let v = Serdes.Deserialize(ser, ootbOptions) in Choice1Of2 v with e -> Choice2Of2 e.Message + test <@ match res with + | Choice1Of2 v -> v = value + | Choice2Of2 m -> m.Contains "Deserialization of reference types without parameterless constructor is not supported. Type 'FsCodec.SystemTextJson.Tests.SerdesTests+Record'" @> + + let [] ``OOTB STJ options`` () = + let ootbOptionsWithRecordConverter = Options.CreateDefault(converters = [|Serialization.JsonRecordConverter()|]) + let value = { a = 1; b = Some "str" } + let ser = Serdes.Serialize(value, ootbOptions) + test <@ ser = """{"a":1,"b":{"Value":"str"}}""" @> + let correctSer = """{"a":1,"b":"str"}""" + let res = try let v = Serdes.Deserialize(correctSer, ootbOptionsWithRecordConverter) in Choice1Of2 v with e -> Choice2Of2 e.Message + test <@ match res with + | Choice1Of2 v -> v = value + | Choice2Of2 m -> m.Contains "The JSON value could not be converted to Microsoft.FSharp.Core.FSharpOption`1[System.String]" @> + +(* Serdes + default Options behavior, i.e. the stuff we do *) + +let [] records () = + let value = { a = 1 } + let res = Serdes.Serialize value + test <@ res = """{"a":1}""" @> + let des = Serdes.Deserialize res + test <@ value = des @> + +let [] options () = + let value = { a = 1; b = Some "str" } + let ser = Serdes.Serialize value + test <@ ser = """{"a":1,"b":"str"}""" @> + let des = Serdes.Deserialize ser + test <@ value = des @>