Skip to content

Commit 080e207

Browse files
committed
Add FsCodec.SystemTextJson
1 parent 7f67669 commit 080e207

File tree

9 files changed

+236
-26
lines changed

9 files changed

+236
-26
lines changed

FsCodec.sln

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec", "src\FsCodec\FsCo
2424
EndProject
2525
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec.Tests", "tests\FsCodec.Tests\FsCodec.Tests.fsproj", "{0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}"
2626
EndProject
27+
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec.SystemTextJson", "src\FsCodec.SystemTextJson\FsCodec.SystemTextJson.fsproj", "{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}"
28+
EndProject
29+
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec.SystemTextJson.Tests", "tests\FsCodec.SystemTextJson.Tests\FsCodec.SystemTextJson.Tests.fsproj", "{5C57C6D6-59AB-426F-9999-FDB90864545E}"
30+
EndProject
2731
Global
2832
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2933
Debug|Any CPU = Debug|Any CPU
@@ -82,6 +86,30 @@ Global
8286
{0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Release|x64.Build.0 = Release|Any CPU
8387
{0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Release|x86.ActiveCfg = Release|Any CPU
8488
{0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Release|x86.Build.0 = Release|Any CPU
89+
{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
90+
{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|Any CPU.Build.0 = Debug|Any CPU
91+
{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|x64.ActiveCfg = Debug|Any CPU
92+
{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|x64.Build.0 = Debug|Any CPU
93+
{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|x86.ActiveCfg = Debug|Any CPU
94+
{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|x86.Build.0 = Debug|Any CPU
95+
{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|Any CPU.ActiveCfg = Release|Any CPU
96+
{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|Any CPU.Build.0 = Release|Any CPU
97+
{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|x64.ActiveCfg = Release|Any CPU
98+
{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|x64.Build.0 = Release|Any CPU
99+
{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|x86.ActiveCfg = Release|Any CPU
100+
{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|x86.Build.0 = Release|Any CPU
101+
{5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
102+
{5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|Any CPU.Build.0 = Debug|Any CPU
103+
{5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|x64.ActiveCfg = Debug|Any CPU
104+
{5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|x64.Build.0 = Debug|Any CPU
105+
{5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|x86.ActiveCfg = Debug|Any CPU
106+
{5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|x86.Build.0 = Debug|Any CPU
107+
{5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|Any CPU.ActiveCfg = Release|Any CPU
108+
{5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|Any CPU.Build.0 = Release|Any CPU
109+
{5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|x64.ActiveCfg = Release|Any CPU
110+
{5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|x64.Build.0 = Release|Any CPU
111+
{5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|x86.ActiveCfg = Release|Any CPU
112+
{5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|x86.Build.0 = Release|Any CPU
85113
EndGlobalSection
86114
GlobalSection(SolutionProperties) = preSolution
87115
HideSolutionNode = FALSE

build.proj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@
1414
<Target Name="Pack">
1515
<Exec Command="dotnet pack src/FsCodec $(Cfg) $(PackOptions)" />
1616
<Exec Command="dotnet pack src/FsCodec.NewtonsoftJson $(Cfg) $(PackOptions)" />
17+
<Exec Command="dotnet pack src/FsCodec.SystemTextJson $(Cfg) $(PackOptions)" />
1718
</Target>
1819

1920
<Target Name="VSTest">
2021
<Exec Command="dotnet test tests/FsCodec.Tests $(Cfg) $(TestOptions)" />
2122
<Exec Command="dotnet test tests/FsCodec.NewtonsoftJson.Tests $(Cfg) $(TestOptions)" />
23+
<Exec Command="dotnet test tests/FsCodec.SystemTextJson.Tests $(Cfg) $(TestOptions)" />
2224
</Target>
2325

2426
<Target Name="Build" DependsOnTargets="VSTest;Pack" />
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.1</TargetFramework>
5+
<WarningLevel>5</WarningLevel>
6+
<IsTestProject>false</IsTestProject>
7+
<DisableImplicitFSharpCoreReference>true</DisableImplicitFSharpCoreReference>
8+
<DisableImplicitSystemValueTupleReference>true</DisableImplicitSystemValueTupleReference>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<Compile Include="JsonElementHelpers.fs" />
13+
<Compile Include="Utf8JsonReaderExtensions.fs" />
14+
<Compile Include="JsonOptionConverter.fs" />
15+
<Compile Include="JsonRecordConverter.fs" />
16+
<Compile Include="Options.fs" />
17+
<Compile Include="Serdes.fs" />
18+
</ItemGroup>
19+
20+
<ItemGroup>
21+
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
22+
<PackageReference Include="MinVer" Version="2.0.0" PrivateAssets="All" />
23+
24+
<PackageReference Include="FSharp.Core" Version="4.3.4" Condition=" '$(TargetFramework)' == 'netstandard2.1' " />
25+
26+
<PackageReference Include="System.Text.Json" Version="4.7.0" />
27+
</ItemGroup>
28+
29+
<ItemGroup>
30+
<ProjectReference Include="../FsCodec/FsCodec.fsproj" />
31+
</ItemGroup>
32+
33+
</Project>

src/FsCodec.SystemTextJson/JsonRecordConverter.fs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
namespace FsCodec.SystemTextJson.Serialization
22

3-
open Equinox.Core
3+
open FSharp.Reflection
44
open System
55
open System.Collections.Generic
6-
open System.Linq
76
open System.Linq.Expressions
87
open System.Text.Json
98
open System.Text.Json.Serialization
10-
open FSharp.Reflection
119

1210
type JsonRecordConverterActivator = delegate of JsonSerializerOptions -> JsonConverter
1311

@@ -55,7 +53,7 @@ type JsonRecordConverter<'T> (options: JsonSerializerOptions) =
5553
|> Array.tryHead
5654
|> Option.map (fun attr -> (attr :?> JsonPropertyNameAttribute).Name)
5755
|> Option.defaultWith (fun () ->
58-
if options.PropertyNamingPolicy |> isNull
56+
if options.PropertyNamingPolicy |> isNull
5957
then f.Name
6058
else options.PropertyNamingPolicy.ConvertName f.Name)
6159

@@ -92,7 +90,7 @@ type JsonRecordConverter<'T> (options: JsonSerializerOptions) =
9290
|> Array.map KeyValuePair
9391
|> (fun kvp -> kvp.ToDictionary((fun item -> item.Key), (fun item -> item.Value), StringComparer.OrdinalIgnoreCase))
9492
#endif
95-
93+
9694
let tryGetFieldByName name =
9795
match fieldsByName.TryGetValue(name) with
9896
| true, field -> Some field
@@ -107,15 +105,15 @@ type JsonRecordConverter<'T> (options: JsonSerializerOptions) =
107105
reader.ValidateTokenType(JsonTokenType.PropertyName)
108106

109107
match tryGetFieldByName <| reader.GetString() with
110-
| Some field ->
108+
| Some field ->
111109
fields.[field.index] <-
112110
match field.converter with
113111
| Some converter ->
114112
reader.Read() |> ignore
115113
converter.Read(&reader, field.fieldType, options)
116114
| None ->
117115
JsonSerializer.Deserialize(&reader, field.fieldType, options)
118-
| _ ->
116+
| _ ->
119117
reader.Skip()
120118

121119
constructor fields :?> 'T

src/FsCodec.SystemTextJson/Options.fs

100644100755
Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,52 @@
1-
namespace FsCodec.SystemTextJson.Serialization
1+
namespace FsCodec.SystemTextJson
22

3+
open FsCodec.SystemTextJson.Serialization
4+
open System
5+
open System.Runtime.InteropServices
36
open System.Text.Json
7+
open System.Text.Json.Serialization
48

5-
[<AutoOpen>]
6-
module JsonSerializerOptionExtensions =
7-
type JsonSerializerOptions with
8-
static member Create() =
9-
let options = JsonSerializerOptions()
10-
options.Converters.Add(new JsonRecordConverter())
11-
options
9+
type Options private () =
1210

13-
module JsonSerializer =
14-
let defaultOptions = JsonSerializerOptions.Create()
11+
static let defaultConverters : JsonConverterFactory[] = [| JsonOptionConverter(); JsonRecordConverter() |]
12+
13+
/// Creates a default set of serializer options used by Json serialization. When used with no args, same as `JsonSerializerOptions()`
14+
static member CreateDefault
15+
( [<Optional; ParamArray>] converters : JsonConverterFactory[],
16+
/// Use multi-line, indented formatting when serializing JSON; defaults to false.
17+
[<Optional; DefaultParameterValue(null)>] ?indent : bool,
18+
/// Render idiomatic camelCase for PascalCase items by using `PropertyNamingPolicy = CamelCase`. Defaults to false.
19+
[<Optional; DefaultParameterValue(null)>] ?camelCase : bool,
20+
/// Ignore null values in input data; defaults to false.
21+
[<Optional; DefaultParameterValue(null)>] ?ignoreNulls : bool) =
22+
23+
let indent = defaultArg indent false
24+
let camelCase = defaultArg camelCase false
25+
let ignoreNulls = defaultArg ignoreNulls false
26+
let options = JsonSerializerOptions()
27+
if converters <> null then converters |> Array.iter options.Converters.Add
28+
if indent then options.WriteIndented <- true
29+
if camelCase then options.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase; options.DictionaryKeyPolicy <- JsonNamingPolicy.CamelCase
30+
if ignoreNulls then options.IgnoreNullValues <- true
31+
options
32+
33+
/// Opinionated helper that creates serializer settings that provide good defaults for F#
34+
/// - Always prepends `[JsonOptionConverter(); JsonRecordConverter()]` to any converters supplied
35+
/// - no camel case conversion - assumption is you'll use records with camelCased names
36+
/// Everything else is as per CreateDefault:- i.e. emit nulls instead of omitting fields, no indenting, no camelCase conversion
37+
static member Create
38+
( /// List of converters to apply. Implicit [JsonOptionConverter(); JsonRecordConverter()] will be prepended and/or be used as a default
39+
[<Optional; ParamArray>] converters : JsonConverterFactory[],
40+
/// Use multi-line, indented formatting when serializing JSON; defaults to false.
41+
[<Optional; DefaultParameterValue(null)>] ?indent : bool,
42+
/// Render idiomatic camelCase for PascalCase items by using `PropertyNamingPolicy = CamelCase`.
43+
/// Defaults to false on basis that you'll use record and tuple field names that are camelCase (but thus not `CLSCompliant`).
44+
[<Optional; DefaultParameterValue(null)>] ?camelCase : bool,
45+
/// Ignore null values in input data; defaults to `false`.
46+
[<Optional; DefaultParameterValue(null)>] ?ignoreNulls : bool) =
47+
48+
Options.CreateDefault(
49+
converters = (match converters with null | [||] -> defaultConverters | xs -> Array.append defaultConverters xs),
50+
?ignoreNulls = ignoreNulls,
51+
?indent = indent,
52+
?camelCase = camelCase)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
namespace FsCodec.SystemTextJson
2+
3+
open System.Runtime.InteropServices
4+
open System.Text.Json
5+
6+
/// Serializes to/from strings using the Options arising from a call to <c>Options.Create()</c>
7+
type Serdes private () =
8+
9+
static let defaultOptions = lazy Options.Create()
10+
static let indentOptions = lazy Options.Create(indent = true)
11+
12+
/// Serializes given value to a JSON string.
13+
static member Serialize<'T>
14+
( /// Value to serialize.
15+
value : 'T,
16+
/// Use indentation when serializing JSON. Defaults to false.
17+
[<Optional; DefaultParameterValue null>] ?indent : bool) : string =
18+
let options = (if defaultArg indent false then indentOptions else defaultOptions).Value
19+
JsonSerializer.Serialize(value, options)
20+
21+
/// Serializes given value to a JSON string with custom options
22+
static member Serialize<'T>
23+
( /// Value to serialize.
24+
value : 'T,
25+
/// Options to use (use other overload to use Options.Create() profile)
26+
options : JsonSerializerOptions) : string =
27+
JsonSerializer.Serialize(value, options)
28+
29+
/// Deserializes value of given type from JSON string.
30+
static member Deserialize<'T>
31+
( /// Json string to deserialize.
32+
json : string,
33+
/// Options to use (defaults to Options.Create() profile)
34+
[<Optional; DefaultParameterValue null>] ?options : JsonSerializerOptions) : 'T =
35+
let settings = match options with None -> defaultOptions.Value | Some x -> x
36+
JsonSerializer.Deserialize<'T>(json, settings)
Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
namespace FsCodec.SystemTextJson.Serialization
22

3-
open System.Text.Json
43
open System.Runtime.CompilerServices
4+
open System.Text.Json
55

66
[<Extension>]
77
type Utf8JsonReaderExtension =
@@ -12,11 +12,11 @@ type Utf8JsonReaderExtension =
1212
|> JsonException
1313
|> raise
1414

15-
[<Extension>]
16-
static member ValidatePropertyName(reader: Utf8JsonReader, expectedPropertyName: string) =
17-
reader.ValidateTokenType(JsonTokenType.PropertyName)
18-
19-
if not <| reader.ValueTextEquals expectedPropertyName then
20-
sprintf "Expected a property named '%s', but encountered property with name '%s'." expectedPropertyName (reader.GetString())
21-
|> JsonException
22-
|> raise
15+
// [<Extension>]
16+
// static member ValidatePropertyName(reader: Utf8JsonReader, expectedPropertyName: string) =
17+
// reader.ValidateTokenType(JsonTokenType.PropertyName)
18+
//
19+
// if not <| reader.ValueTextEquals expectedPropertyName then
20+
// sprintf "Expected a property named '%s', but encountered property with name '%s'." expectedPropertyName (reader.GetString())
21+
// |> JsonException
22+
// |> raise
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netcoreapp3.1</TargetFramework>
5+
<WarningLevel>5</WarningLevel>
6+
<IsPackable>false</IsPackable>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
11+
<PackageReference Include="Unquote" Version="5.0.0" />
12+
<PackageReference Include="xunit" Version="2.4.1" />
13+
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" />
14+
</ItemGroup>
15+
16+
<ItemGroup>
17+
<ProjectReference Include="../../src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj" />
18+
</ItemGroup>
19+
20+
<ItemGroup>
21+
<Compile Include="SerdesTests.fs" />
22+
</ItemGroup>
23+
24+
</Project>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
module FsCodec.SystemTextJson.Tests.SerdesTests
2+
3+
open FsCodec.SystemTextJson
4+
open Swensen.Unquote
5+
open Xunit
6+
7+
type Record = { a : int }
8+
9+
type RecordWithOption = { a : int; b : string option }
10+
11+
/// Characterization tests for OOTB JSON.NET
12+
/// 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
13+
module StjCharacterization =
14+
let ootbOptions = Options.CreateDefault()
15+
16+
let [<Fact>] ``OOTB STJ records`` () =
17+
let value = { a = 1 }
18+
let ser = Serdes.Serialize(value, ootbOptions)
19+
test <@ ser = """{"a":1}""" @>
20+
21+
let res = try let v = Serdes.Deserialize(ser, ootbOptions) in Choice1Of2 v with e -> Choice2Of2 e.Message
22+
test <@ match res with
23+
| Choice1Of2 v -> v = value
24+
| Choice2Of2 m -> m.Contains "Deserialization of reference types without parameterless constructor is not supported. Type 'FsCodec.SystemTextJson.Tests.SerdesTests+Record'" @>
25+
26+
let [<Fact>] ``OOTB STJ options`` () =
27+
let ootbOptionsWithRecordConverter = Options.CreateDefault(converters = [|Serialization.JsonRecordConverter()|])
28+
let value = { a = 1; b = Some "str" }
29+
let ser = Serdes.Serialize(value, ootbOptions)
30+
test <@ ser = """{"a":1,"b":{"Value":"str"}}""" @>
31+
let correctSer = """{"a":1,"b":"str"}"""
32+
let res = try let v = Serdes.Deserialize(correctSer, ootbOptionsWithRecordConverter) in Choice1Of2 v with e -> Choice2Of2 e.Message
33+
test <@ match res with
34+
| Choice1Of2 v -> v = value
35+
| Choice2Of2 m -> m.Contains "The JSON value could not be converted to Microsoft.FSharp.Core.FSharpOption`1[System.String]" @>
36+
37+
(* Serdes + default Options behavior, i.e. the stuff we do *)
38+
39+
let [<Fact>] records () =
40+
let value = { a = 1 }
41+
let res = Serdes.Serialize value
42+
test <@ res = """{"a":1}""" @>
43+
let des = Serdes.Deserialize res
44+
test <@ value = des @>
45+
46+
let [<Fact>] options () =
47+
let value = { a = 1; b = Some "str" }
48+
let ser = Serdes.Serialize value
49+
test <@ ser = """{"a":1,"b":"str"}""" @>
50+
let des = Serdes.Deserialize ser
51+
test <@ value = des @>

0 commit comments

Comments
 (0)