Skip to content

Commit

Permalink
Add FsCodec.SystemTextJson
Browse files Browse the repository at this point in the history
  • Loading branch information
bartelink committed Mar 5, 2020
1 parent 7f67669 commit 3d5a60c
Show file tree
Hide file tree
Showing 9 changed files with 236 additions and 26 deletions.
28 changes: 28 additions & 0 deletions FsCodec.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions build.proj
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
<Target Name="Pack">
<Exec Command="dotnet pack src/FsCodec $(Cfg) $(PackOptions)" />
<Exec Command="dotnet pack src/FsCodec.NewtonsoftJson $(Cfg) $(PackOptions)" />
<Exec Command="dotnet pack src/FsCodec.SystemTextJson $(Cfg) $(PackOptions)" />
</Target>

<Target Name="VSTest">
<Exec Command="dotnet test tests/FsCodec.Tests $(Cfg) $(TestOptions)" />
<Exec Command="dotnet test tests/FsCodec.NewtonsoftJson.Tests $(Cfg) $(TestOptions)" />
<Exec Command="dotnet test tests/FsCodec.SystemTextJson.Tests $(Cfg) $(TestOptions)" />
</Target>

<Target Name="Build" DependsOnTargets="VSTest;Pack" />
Expand Down
33 changes: 33 additions & 0 deletions src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<WarningLevel>5</WarningLevel>
<IsTestProject>false</IsTestProject>
<DisableImplicitFSharpCoreReference>true</DisableImplicitFSharpCoreReference>
<DisableImplicitSystemValueTupleReference>true</DisableImplicitSystemValueTupleReference>
</PropertyGroup>

<ItemGroup>
<Compile Include="JsonElementHelpers.fs" />
<Compile Include="Utf8JsonReaderExtensions.fs" />
<Compile Include="JsonOptionConverter.fs" />
<Compile Include="JsonRecordConverter.fs" />
<Compile Include="Options.fs" />
<Compile Include="Serdes.fs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
<PackageReference Include="MinVer" Version="2.0.0" PrivateAssets="All" />

<PackageReference Include="FSharp.Core" Version="4.3.4" Condition=" '$(TargetFramework)' == 'netstandard2.1' " />

<PackageReference Include="System.Text.Json" Version="4.7.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="../FsCodec/FsCodec.fsproj" />
</ItemGroup>

</Project>
12 changes: 5 additions & 7 deletions src/FsCodec.SystemTextJson/JsonRecordConverter.fs
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -107,15 +105,15 @@ 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 ->
reader.Read() |> ignore
converter.Read(&reader, field.fieldType, options)
| None ->
JsonSerializer.Deserialize(&reader, field.fieldType, options)
| _ ->
| _ ->
reader.Skip()

constructor fields :?> 'T
Expand Down
58 changes: 48 additions & 10 deletions src/FsCodec.SystemTextJson/Options.fs
100644 → 100755
Original file line number Diff line number Diff line change
@@ -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

[<AutoOpen>]
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
( [<Optional; ParamArray>] converters : JsonConverterFactory[],
/// Use multi-line, indented formatting when serializing JSON; defaults to false.
[<Optional; DefaultParameterValue(null)>] ?indent : bool,
/// Render idiomatic camelCase for PascalCase items by using `PropertyNamingPolicy = CamelCase`. Defaults to false.
[<Optional; DefaultParameterValue(null)>] ?camelCase : bool,
/// Ignore null values in input data; defaults to false.
[<Optional; DefaultParameterValue(null)>] ?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
[<Optional; ParamArray>] converters : JsonConverterFactory[],
/// Use multi-line, indented formatting when serializing JSON; defaults to false.
[<Optional; DefaultParameterValue(null)>] ?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`).
[<Optional; DefaultParameterValue(null)>] ?camelCase : bool,
/// Ignore null values in input data; defaults to `false`.
[<Optional; DefaultParameterValue(null)>] ?ignoreNulls : bool) =

Options.CreateDefault(
converters = (match converters with null | [||] -> defaultConverters | xs -> Array.append defaultConverters xs),
?ignoreNulls = ignoreNulls,
?indent = indent,
?camelCase = camelCase)
36 changes: 36 additions & 0 deletions src/FsCodec.SystemTextJson/Serdes.fs
Original file line number Diff line number Diff line change
@@ -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 <c>Options.Create()</c>
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.
[<Optional; DefaultParameterValue null>] ?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)
[<Optional; DefaultParameterValue null>] ?options : JsonSerializerOptions) : 'T =
let settings = match options with None -> defaultOptions.Value | Some x -> x
JsonSerializer.Deserialize<'T>(json, settings)
18 changes: 9 additions & 9 deletions src/FsCodec.SystemTextJson/Utf8JsonReaderExtensions.fs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace FsCodec.SystemTextJson.Serialization

open System.Text.Json
open System.Runtime.CompilerServices
open System.Text.Json

[<Extension>]
type Utf8JsonReaderExtension =
Expand All @@ -12,11 +12,11 @@ type Utf8JsonReaderExtension =
|> JsonException
|> raise

[<Extension>]
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
// [<Extension>]
// 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<WarningLevel>5</WarningLevel>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
<PackageReference Include="Unquote" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="../../src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj" />
</ItemGroup>

<ItemGroup>
<Compile Include="SerdesTests.fs" />
</ItemGroup>

</Project>
51 changes: 51 additions & 0 deletions tests/FsCodec.SystemTextJson.Tests/SerdesTests.fs
Original file line number Diff line number Diff line change
@@ -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 [<Fact>] ``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 [<Fact>] ``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 [<Fact>] records () =
let value = { a = 1 }
let res = Serdes.Serialize value
test <@ res = """{"a":1}""" @>
let des = Serdes.Deserialize res
test <@ value = des @>

let [<Fact>] 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 @>

0 comments on commit 3d5a60c

Please sign in to comment.