Skip to content

Commit 638fe21

Browse files
committed
RejectNullConverter WIP
1 parent 39cd706 commit 638fe21

File tree

4 files changed

+76
-2
lines changed

4 files changed

+76
-2
lines changed

src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<Compile Include="TypeSafeEnumConverter.fs" />
1111
<Compile Include="RejectNullStringConverter.fs" />
1212
<Compile Include="UnionOrTypeSafeEnumConverterFactory.fs" />
13+
<Compile Include="RejectNullConverter.fs" />
1314
<Compile Include="Options.fs" />
1415
<Compile Include="Serdes.fs" />
1516
<Compile Include="Codec.fs" />
@@ -22,7 +23,7 @@
2223

2324
<PackageReference Include="FSharp.Core" Version="4.5.4" />
2425

25-
<PackageReference Include="System.Text.Json" Version="6.0.1" />
26+
<PackageReference Include="System.Text.Json" Version="8.0.1" />
2627
</ItemGroup>
2728

2829
<ItemGroup>

src/FsCodec.SystemTextJson/Options.fs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,18 @@ type Options private () =
6060
[<Optional; DefaultParameterValue(null)>] ?autoUnionToJsonObject : bool,
6161
// Apply <c>RejectNullStringConverter</c> in order to have serialization throw on <c>null</c> strings.
6262
// Use <c>string option</c> to represent strings that can potentially be <c>null</c>.
63-
[<Optional; DefaultParameterValue(null)>] ?rejectNullStrings: bool) =
63+
[<Optional; DefaultParameterValue(null)>] ?rejectNullStrings: bool,
64+
// Apply <c>RejectNullConverter</c> in order to have serialization throw on <c>null</c> on <c>null</c> or missing <c>list</c> or <c>Set</c> values.
65+
// Wrap the type in <c>option</c> to represent values that can potentially be <c>null</c> or missing
66+
[<Optional; DefaultParameterValue(null)>] ?rejectNull: bool) =
6467
let autoTypeSafeEnumToJsonString = defaultArg autoTypeSafeEnumToJsonString false
6568
let autoUnionToJsonObject = defaultArg autoUnionToJsonObject false
6669
let rejectNullStrings = defaultArg rejectNullStrings false
6770

6871
Options.CreateDefault(
6972
converters = [|
7073
if rejectNullStrings then RejectNullStringConverter()
74+
if defaultArg rejectNull false then RejectNullConverterFactory()
7175
if autoTypeSafeEnumToJsonString || autoUnionToJsonObject then
7276
UnionOrTypeSafeEnumConverterFactory(typeSafeEnum = autoTypeSafeEnumToJsonString, union = autoUnionToJsonObject)
7377
if converters <> null then yield! converters
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
namespace FsCodec.SystemTextJson
2+
3+
open System
4+
open System.Linq.Expressions
5+
open System.Text.Json
6+
open System.Text.Json.Serialization
7+
8+
type RejectNullConverter<'T>() =
9+
inherit System.Text.Json.Serialization.JsonConverter<'T>()
10+
11+
static let defaultConverter = JsonSerializerOptions.Default.GetConverter(typeof<'T>) :?> JsonConverter<'T>
12+
let msg () = sprintf "Expected value, got null. When rejectNull is true you must explicitly wrap optional %s values in an 'option'" typeof<'T>.Name
13+
14+
override _.HandleNull = true
15+
16+
override _.Read(reader, typeToConvert, options) =
17+
if reader.TokenType = JsonTokenType.Null then msg () |> nullArg else
18+
defaultConverter.Read(&reader, typeToConvert, options)
19+
// Pretty sure the above is the correct approach (and this unsurprisingly loops, blowing the stack)
20+
// JsonSerializer.Deserialize(&reader, typeToConvert, options) :?> 'T
21+
22+
override _.Write(writer, value, options) =
23+
if value |> box |> isNull then msg () |> nullArg
24+
defaultConverter.Write(writer, value, options)
25+
// JsonSerializer.Serialize<'T>(writer, value, options)
26+
27+
type RejectNullConverterFactory(predicate) =
28+
inherit JsonConverterFactory()
29+
new() =
30+
RejectNullConverterFactory(fun (t: Type) ->
31+
t.IsGenericType
32+
&& let gtd = t.GetGenericTypeDefinition() in gtd = typedefof<Set<_>> || gtd = typedefof<list<_>>)
33+
override _.CanConvert(t: Type) = predicate t
34+
35+
override _.CreateConverter(t, _options) =
36+
let openConverterType = typedefof<RejectNullConverter<_>>
37+
let constructor = openConverterType.MakeGenericType(t).GetConstructors() |> Array.head
38+
let newExpression = Expression.New(constructor)
39+
let lambda = Expression.Lambda(typeof<ConverterActivator>, newExpression)
40+
41+
let activator = lambda.Compile() :?> ConverterActivator
42+
activator.Invoke()

tests/FsCodec.SystemTextJson.Tests/SerdesTests.fs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ module FsCodec.SystemTextJson.Tests.SerdesTests
22

33
open System
44
open System.Collections.Generic
5+
open System.Text.Json.Serialization.Metadata
56
open FsCodec.SystemTextJson
67
open Swensen.Unquote
78
open Xunit
@@ -79,6 +80,32 @@ let [<Fact>] ``RejectNullStringConverter rejects null strings`` () =
7980
let value = { c = 1; d = null }
8081
raises<ArgumentNullException> <@ serdes.Serialize value @>
8182

83+
type WithList = { x: int; y: list<int> }
84+
85+
let [<Fact>] ``RejectNullConverter rejects null lists and Sets`` () =
86+
#if false // requires WithList to be CLIMutable, which would be a big imposition
87+
let tir =
88+
DefaultJsonTypeInfoResolver()
89+
.WithAddedModifier(fun x ->
90+
// if x.Kind <> JsonTypeInfoKind.Object then
91+
for p in x.Properties do
92+
let pt = p.PropertyType
93+
if pt.IsGenericType && (let gtd = pt.GetGenericTypeDefinition() in gtd = typedefof<list<_>> || gtd = typedefof<Set<_>>) then
94+
p.IsRequired <- true)
95+
let serdes = Options.Create(TypeInfoResolver = tir) |> Serdes
96+
#else
97+
let serdes = Options.Create(rejectNull = true) |> Serdes
98+
#endif
99+
100+
// Fails with NRE when RejectNullConverter delegates to Default list<int> Converter
101+
// seems akin to https://github.com/dotnet/runtime/issues/86483
102+
let res = serdes.Deserialize<WithList> """{"x":0,"y":[1]}"""
103+
test <@ [1] = res.y @>
104+
105+
raises<exn> <@ serdes.Deserialize<WithList> """{"x":0}""" @>
106+
// PROBLEM: there doesn't seem to be a way to intercept explicitly passed nulls
107+
// raises<JsonException> <@ serdes.Deserialize<WithList> """{"x":0,"y":null}""" @>
108+
82109
let [<Fact>] ``RejectNullStringConverter serializes strings correctly`` () =
83110
let serdes = Serdes(Options.Create(rejectNullStrings = true))
84111
let value = { c = 1; d = "some string" }

0 commit comments

Comments
 (0)