Skip to content

Commit 49cbe22

Browse files
authored
SystemTextJson: Support automatic TypeSafeEnum/Union converter selection (#69)
1 parent bed22c3 commit 49cbe22

File tree

10 files changed

+109
-74
lines changed

10 files changed

+109
-74
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ The `Unreleased` section name is replaced by the expected version of next releas
99
## [Unreleased]
1010

1111
### Added
12+
13+
- `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)
14+
- `SystemTextJson.Options(autoUnion = true)`: Automated wireup of `UnionOrTypeSafeEnumConverterFactory` [#69](https://github.com/jet/FsCodec/pull/69)
15+
1216
### Changed
1317

1418
- `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)

README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,11 @@ The respective concrete Codec packages include relevant `Converter`/`JsonConvert
9595

9696
- [`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.
9797
- [`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`).
98-
98+
99+
### `FsCodec.SystemTextJson`-specific low level converters
100+
101+
- `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.
102+
99103
## `FsCodec.NewtonsoftJson.Settings`
100104

101105
[`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
110114
[`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:
111115
- `CreateDefault`: equivalent to generating a `new JsonSerializerSettings()` without any overrides of any kind
112116
- `Create`: as `CreateDefault` with the following difference:
113-
- Inhibits the HTML-safe escaping that `System.Text.Json` provides as a default by overriding `Encoder` with `System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping`
117+
- 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`
118+
- `(camelCase = true)`: opts into camel case conversion for `PascalCased` properties and `Dictionary` keys
119+
- `(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
114120

115121
## `Serdes`
116122

@@ -150,7 +156,11 @@ This adds all the converters used by the `serdes` serialization/deserialization
150156
<a name="aspnetstj"></a>
151157
## ASP.NET Core with `System.Text.Json`
152158

153-
The equivalent for the native `System.Text.Json` looks like this:
159+
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.
160+
161+
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:
162+
163+
let serdes = FsCodec.SystemTextJson.Options.Create(autoUnion = true) |> FsCodec.SystemTextJson.Serdes
154164

155165
let serdes = FsCodec.SystemTextJson.Options.Create() |> FsCodec.SystemTextJson.Serdes
156166

@@ -159,8 +169,6 @@ The equivalent for the native `System.Text.Json` looks like this:
159169
serdes.Options.Converters |> Seq.iter options.JsonSerializerOptions.Converters.Add
160170
) |> ignore
161171

162-
_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._
163-
164172
# Examples: `FsCodec.(Newtonsoft|SystemText)Json`
165173

166174
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...)

src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<Compile Include="Pickler.fs" />
1212
<Compile Include="UnionConverter.fs" />
1313
<Compile Include="TypeSafeEnumConverter.fs" />
14+
<Compile Include="UnionOrTypeSafeEnumConverterFactory.fs" />
1415
<Compile Include="Options.fs" />
1516
<Compile Include="Codec.fs" />
1617
<Compile Include="Serdes.fs" />

src/FsCodec.SystemTextJson/Options.fs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ type Options private () =
5454
[<Optional; DefaultParameterValue(null)>] ?autoUnion : bool) =
5555

5656
Options.CreateDefault(
57-
converters = converters,
57+
converters =
58+
( if autoUnion = Some true then
59+
let converter : JsonConverter array = [| UnionOrTypeSafeEnumConverterFactory() |]
60+
if converters = null then converter else Array.append converters converter
61+
else converters),
5862
?ignoreNulls = ignoreNulls,
5963
?indent = indent,
6064
?camelCase = camelCase,

src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,12 @@ open System.Text.Json
77
/// Utilities for working with DUs where none of the cases have a value
88
module TypeSafeEnum =
99

10-
let private _isTypeSafeEnum (t : Type) =
11-
Union.isUnion t
12-
&& (Union.getUnion t).cases |> Seq.forall (fun case -> case.GetFields().Length = 0)
13-
let isTypeSafeEnum : Type -> bool = memoize _isTypeSafeEnum
10+
let isTypeSafeEnum (typ : Type) =
11+
Union.isUnion typ
12+
&& Union.hasOnlyNullaryCases typ
1413

1514
let tryParseT (t : Type) predicate =
16-
if not (Union.isUnion t) then invalidArg "t" "Type must be a FSharpUnion." else
17-
18-
let u = Union.getUnion t
15+
let u = Union.getInfo t
1916
u.cases
2017
|> Array.tryFindIndex (fun c -> predicate c.Name)
2118
|> Option.map (fun tag -> u.caseConstructor.[tag] [||])
@@ -31,9 +28,7 @@ module TypeSafeEnum =
3128
let parse<'T> (str : string) = parseT typeof<'T> str :?> 'T
3229

3330
let toString<'t> (x : 't) =
34-
if not (Union.isUnion (typeof<'t>)) then invalidArg "'t" "Type must be a FSharpUnion." else
35-
36-
let u = Union.getUnion (typeof<'t>)
31+
let u = Union.getInfo typeof<'t>
3732
let tag = u.tagReader (box x)
3833
// TOCONSIDER memoize and/or push into `Union` https://github.com/jet/FsCodec/pull/41#discussion_r394473137
3934
u.cases.[tag].Name

src/FsCodec.SystemTextJson/UnionConverter.fs

Lines changed: 30 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,15 @@
22

33
open FSharp.Reflection
44
open System
5-
open System.Reflection
65
open System.Text.Json
76

87
type IUnionConverterOptions =
98
abstract member Discriminator : string with get
109
abstract member CatchAllCase : string option with get
1110

12-
/// Use this attribute in combination with a JsonConverter/UnionConverter attribute to specify
13-
/// your own name for a discriminator and/or a catch-all case for a specific discriminated union.
14-
/// If this attribute is set, its values take precedence over the values set on the converter via its constructor.
15-
/// Example: <c>[<JsonConverter(typeof<UnionConverter<T>>); JsonUnionConverterOptions("type")>]</c>
11+
/// <summary>Use this attribute in combination with a JsonConverter / UnionConverter attribute to specify
12+
/// your own name for a discriminator and/or a catch-all case for a specific discriminated union.</summary>
13+
/// <example><c>[JsonConverter typeof &lt; UnionConverter &lt; T &gt; &gt;); JsonUnionConverterOptions("type") &gt;]</c></example>
1614
[<AttributeUsage(AttributeTargets.Class ||| AttributeTargets.Struct, AllowMultiple = false, Inherited = false)>]
1715
type JsonUnionConverterOptionsAttribute(discriminator : string) =
1816
inherit Attribute()
@@ -21,74 +19,41 @@ type JsonUnionConverterOptionsAttribute(discriminator : string) =
2119
member _.Discriminator = discriminator
2220
member x.CatchAllCase = Option.ofObj x.CatchAllCase
2321

24-
type UnionConverterOptions =
25-
{
26-
discriminator : string
27-
catchAllCase : string option
28-
}
22+
type private UnionConverterOptions =
23+
{ discriminator : string
24+
catchAllCase : string option }
2925
interface IUnionConverterOptions with
3026
member x.Discriminator = x.discriminator
3127
member x.CatchAllCase = x.catchAllCase
3228

3329
[<NoComparison; NoEquality>]
3430
type private Union =
35-
{
36-
cases : UnionCaseInfo[]
31+
{ cases : UnionCaseInfo[]
3732
tagReader : obj -> int
3833
fieldReader : (obj -> obj[])[]
3934
caseConstructor : (obj[] -> obj)[]
40-
options : IUnionConverterOptions option
41-
}
35+
options : IUnionConverterOptions option }
4236

4337
module private Union =
4438

4539
let isUnion : Type -> bool = memoize (fun t -> FSharpType.IsUnion(t, true))
46-
let getUnionCases = memoize (fun t -> FSharpType.GetUnionCases(t, true))
4740

48-
let private createUnion t =
49-
let cases = getUnionCases t
50-
{
51-
cases = cases
41+
let private createInfo t =
42+
let cases = FSharpType.GetUnionCases(t, true)
43+
{ cases = cases
5244
tagReader = FSharpValue.PreComputeUnionTagReader(t, true)
5345
fieldReader = cases |> Array.map (fun c -> FSharpValue.PreComputeUnionReader(c, true))
5446
caseConstructor = cases |> Array.map (fun c -> FSharpValue.PreComputeUnionConstructor(c, true))
5547
options =
5648
t.GetCustomAttributes(typeof<JsonUnionConverterOptionsAttribute>, false)
5749
|> Array.tryHead // AttributeUsage(AllowMultiple = false)
58-
|> Option.map (fun a -> a :?> IUnionConverterOptions)
59-
}
60-
let getUnion : Type -> Union = memoize createUnion
61-
62-
/// Parallels F# behavior wrt how it generates a DU's underlying .NET Type
63-
let inline isInlinedIntoUnionItem (t : Type) =
64-
t = typeof<string>
65-
|| (t.IsValueType && t <> typeof<JsonElement>)
66-
|| t.IsArray
67-
|| (t.IsGenericType
68-
&& (typedefof<Option<_>> = t.GetGenericTypeDefinition()
69-
|| t.GetGenericTypeDefinition().IsValueType)) // Nullable<T>
70-
71-
let typeHasJsonConverterAttribute_ (t : Type) = t.IsDefined(typeof<Serialization.JsonConverterAttribute>(*, false*))
72-
let typeHasJsonConverterAttribute = memoize typeHasJsonConverterAttribute_
73-
let typeIsUnionWithConverterAttribute = memoize (fun (t : Type) -> isUnion t && typeHasJsonConverterAttribute_ t)
74-
75-
let propTypeRequiresConstruction (propertyType : Type) =
76-
not (isInlinedIntoUnionItem propertyType)
77-
&& not (typeHasJsonConverterAttribute propertyType)
78-
79-
/// Prepare arguments for the Case class ctor based on the kind of case and how F# maps that to a Type
80-
/// and/or whether we need to defer to System.Text.Json
81-
let mapTargetCaseArgs (element : JsonElement) (options : JsonSerializerOptions) (props : PropertyInfo[]) : obj [] =
82-
match props with
83-
| [| singleCaseArg |] when propTypeRequiresConstruction singleCaseArg.PropertyType ->
84-
[| JsonSerializer.Deserialize(element, singleCaseArg.PropertyType, options) |]
85-
| multipleFieldsInCustomCaseType ->
86-
[| for fi in multipleFieldsInCustomCaseType ->
87-
match element.TryGetProperty fi.Name with
88-
| false, _ when fi.PropertyType.IsValueType -> Activator.CreateInstance fi.PropertyType
89-
| false, _ -> null
90-
| true, el when el.ValueKind = JsonValueKind.Null -> null
91-
| true, el -> JsonSerializer.Deserialize(el, fi.PropertyType, options) |]
50+
|> Option.map (fun a -> a :?> IUnionConverterOptions) }
51+
let getInfo : Type -> Union = memoize createInfo
52+
53+
/// Allows us to distinguish between Unions that have bodies and hence should UnionConverter
54+
let hasOnlyNullaryCases (t : Type) =
55+
let union = getInfo t
56+
union.cases |> Seq.forall (fun case -> case.GetFields().Length = 0)
9257

9358
type UnionConverter<'T>() =
9459
inherit Serialization.JsonConverter<'T>()
@@ -101,7 +66,7 @@ type UnionConverter<'T>() =
10166

10267
override _.Write(writer, value, options) =
10368
let value = box value
104-
let union = Union.getUnion typeof<'T>
69+
let union = Union.getInfo typeof<'T>
10570
let unionOptions = getOptions union
10671
let tag = union.tagReader value
10772
let case = union.cases.[tag]
@@ -114,8 +79,8 @@ type UnionConverter<'T>() =
11479
for fieldInfo, fieldValue in Seq.zip fieldInfos fieldValues do
11580
if fieldValue <> null || options.DefaultIgnoreCondition <> Serialization.JsonIgnoreCondition.Always then
11681
let element = JsonSerializer.SerializeToElement(fieldValue, fieldInfo.PropertyType, options)
117-
if fieldInfos.Length = 1 && element.ValueKind = JsonValueKind.Object && not (Union.typeIsUnionWithConverterAttribute fieldInfo.PropertyType) then
118-
// flatten the object properties into the same one as the discriminator
82+
if fieldInfos.Length = 1 && FSharpType.IsRecord(fieldInfo.PropertyType, true) then
83+
// flatten the record properties into the same JSON object as the discriminator
11984
for prop in element.EnumerateObject() do
12085
prop.WriteTo writer
12186
else
@@ -127,7 +92,7 @@ type UnionConverter<'T>() =
12792
if reader.TokenType <> JsonTokenType.StartObject then
12893
sprintf "Unexpected token when reading Union: %O" reader.TokenType |> JsonException |> raise
12994
use document = JsonDocument.ParseValue &reader
130-
let union = Union.getUnion typeof<'T>
95+
let union = Union.getInfo typeof<'T>
13196
let unionOptions = getOptions union
13297
let element = document.RootElement
13398

@@ -147,4 +112,11 @@ type UnionConverter<'T>() =
147112
| Some foundIndex -> foundIndex
148113

149114
let targetCaseFields, targetCaseCtor = union.cases.[targetCaseIndex].GetFields(), union.caseConstructor.[targetCaseIndex]
150-
targetCaseCtor (Union.mapTargetCaseArgs element options targetCaseFields) :?> 'T
115+
let ctorArgs =
116+
[| for fieldInfo in targetCaseFields ->
117+
let t = fieldInfo.PropertyType
118+
let targetEl =
119+
if targetCaseFields.Length = 1 && (t = typeof<JsonElement> || FSharpType.IsRecord(t, true)) then element
120+
else let _found, el = element.TryGetProperty fieldInfo.Name in el
121+
JsonSerializer.Deserialize(targetEl, t, options) |]
122+
targetCaseCtor ctorArgs :?> 'T
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace FsCodec.SystemTextJson
2+
3+
open System
4+
open System.Linq.Expressions
5+
open System.Text.Json.Serialization
6+
7+
type internal ConverterActivator = delegate of unit -> JsonConverter
8+
9+
type UnionOrTypeSafeEnumConverterFactory() =
10+
inherit JsonConverterFactory()
11+
12+
override _.CanConvert(t : Type) =
13+
Union.isUnion t
14+
15+
override _.CreateConverter(typ, _options) =
16+
let openConverterType = if Union.hasOnlyNullaryCases typ then typedefof<TypeSafeEnumConverter<_>> else typedefof<UnionConverter<_>>
17+
let constructor = openConverterType.MakeGenericType(typ).GetConstructors() |> Array.head
18+
let newExpression = Expression.New(constructor)
19+
let lambda = Expression.Lambda(typeof<ConverterActivator>, newExpression)
20+
21+
let activator = lambda.Compile() :?> ConverterActivator
22+
activator.Invoke()

tests/FsCodec.NewtonsoftJson.Tests/UnionConverterTests.fs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,7 @@ module ``Struct discriminated unions`` =
487487
| CaseAV of av : TestRecordPayloadStruct
488488
| CaseB
489489
| CaseC of string
490+
| CaseC2 of c2: int
490491
| CaseD of d : string
491492
| CaseE of e : string * int
492493
| CaseF of f : string * fb : int
@@ -511,6 +512,9 @@ module ``Struct discriminated unions`` =
511512
let c = CaseC "hi"
512513
test <@ """{"case":"CaseC","Item":"hi"}""" = serialize c @>
513514

515+
let c2 = CaseC2 2
516+
test <@ """{"case":"CaseC2","c2":2}""" = serialize c2 @>
517+
514518
let d = CaseD "hi"
515519
test <@ """{"case":"CaseD","d":"hi"}""" = serialize d @>
516520

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
module FsCodec.SystemTextJson.Tests.AutoUnionTests
2+
3+
open FsCodec.SystemTextJson
4+
open Swensen.Unquote
5+
6+
type ATypeSafeEnum = A | B | C
7+
type NotAUnion = { body : string }
8+
type AUnion = D of value : string | E of ATypeSafeEnum | F
9+
type Any = Tse of enum : ATypeSafeEnum | Not of NotAUnion | Union of AUnion
10+
11+
let serdes = Options.Create(autoUnion = true) |> Serdes
12+
13+
let [<Xunit.Fact>] ``Basic characteristics`` () =
14+
test <@ "\"B\"" = serdes.Serialize B @>
15+
test <@ "{\"body\":\"A\"}" = serdes.Serialize { body = "A" } @>
16+
test <@ "{\"case\":\"D\",\"value\":\"A\"}" = serdes.Serialize (D "A") @>
17+
test <@ "{\"case\":\"Tse\",\"enum\":\"B\"}" = serdes.Serialize (Tse B) @>
18+
test <@ Tse B = serdes.Deserialize "{\"case\":\"Tse\",\"enum\":\"B\"}" @>
19+
test <@ Not { body = "A" } = serdes.Deserialize "{\"case\":\"Not\",\"body\":\"A\"}" @>
20+
21+
let [<FsCheck.Xunit.Property>] ``auto-encodes Unions and non-unions`` (x : Any) =
22+
let encoded = serdes.Serialize x
23+
let decoded : Any = serdes.Deserialize encoded
24+
test <@ decoded = x @>

tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
<Link>UnionConverterTests.fs</Link>
3636
</Compile>
3737
<Compile Include="InteropTests.fs" />
38+
<Compile Include="AutoUnionTests.fs" />
3839
</ItemGroup>
3940

4041
</Project>

0 commit comments

Comments
 (0)