Skip to content

Commit 491e84b

Browse files
authored
Port UnionConverter to STJ re #43 (#59)
1 parent c746844 commit 491e84b

File tree

6 files changed

+359
-37
lines changed

6 files changed

+359
-37
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ The `Unreleased` section name is replaced by the expected version of next releas
99
## [Unreleased]
1010

1111
### Added
12+
13+
- `SystemTextJson.UnionConverter`: Port of `NewtonsoftJson` equivalent started in [#43](https://github.com/jet/FsCodec/pull/43) [#59](https://github.com/jet/FsCodec/pull/59) :pray: [@NickDarvey](https://github.com/NickDarvey)
14+
1215
### Changed
1316

1417
- `SystemTextJson`: Target `System.Text.Json` v `6.0.1`, `TypeShape` v `10.0.0` [#68](https://github.com/jet/FsCodec/pull/68)

README.md

-4
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,6 @@ The components within this repository are delivered as multi-targeted Nuget pack
2323
- [![System.Text.Json Codec NuGet](https://img.shields.io/nuget/v/FsCodec.SystemTextJson.svg)](https://www.nuget.org/packages/FsCodec.SystemTextJson/) `FsCodec.SystemTextJson`: See [#38](https://github.com/jet/FsCodec/pulls/38): drop in replacement that allows one to retarget from `Newtonsoft.Json` to the .NET Core >= v 3.0 default serializer: `System.Text.Json`, solely by changing the referenced namespace.
2424
- [depends](https://www.fuget.org/packages/FsCodec.SystemTextJson) on `FsCodec`, `System.Text.Json >= 6.0.1`, `TypeShape >= 10`
2525

26-
Deltas in behavior/functionality vs `FsCodec.NewtonsoftJson`:
27-
28-
1. [`UnionConverter` is WIP](https://github.com/jet/FsCodec/pull/43)
29-
3026
# Features: `FsCodec`
3127

3228
The purpose of the `FsCodec` package is to provide a minimal interface on which libraries such as Equinox and Propulsion can depend on in order that they can avoid forcing a specific serialization mechanism.

src/FsCodec.SystemTextJson/UnionConverter.fs

+122
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,33 @@
22

33
open FSharp.Reflection
44
open System
5+
open System.Reflection
6+
open System.Text.Json
7+
8+
type IUnionConverterOptions =
9+
abstract member Discriminator : string with get
10+
abstract member CatchAllCase : string option with get
11+
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>
16+
[<AttributeUsage(AttributeTargets.Class ||| AttributeTargets.Struct, AllowMultiple = false, Inherited = false)>]
17+
type JsonUnionConverterOptionsAttribute(discriminator : string) =
18+
inherit Attribute()
19+
member val CatchAllCase : string = null with get, set
20+
interface IUnionConverterOptions with
21+
member _.Discriminator = discriminator
22+
member x.CatchAllCase = Option.ofObj x.CatchAllCase
23+
24+
type UnionConverterOptions =
25+
{
26+
discriminator : string
27+
catchAllCase : string option
28+
}
29+
interface IUnionConverterOptions with
30+
member x.Discriminator = x.discriminator
31+
member x.CatchAllCase = x.catchAllCase
532

633
[<NoComparison; NoEquality>]
734
type private Union =
@@ -10,6 +37,7 @@ type private Union =
1037
tagReader : obj -> int
1138
fieldReader : (obj -> obj[])[]
1239
caseConstructor : (obj[] -> obj)[]
40+
options : IUnionConverterOptions option
1341
}
1442

1543
module private Union =
@@ -24,5 +52,99 @@ module private Union =
2452
tagReader = FSharpValue.PreComputeUnionTagReader(t, true)
2553
fieldReader = cases |> Array.map (fun c -> FSharpValue.PreComputeUnionReader(c, true))
2654
caseConstructor = cases |> Array.map (fun c -> FSharpValue.PreComputeUnionConstructor(c, true))
55+
options =
56+
t.GetCustomAttributes(typeof<JsonUnionConverterOptionsAttribute>, false)
57+
|> Array.tryHead // AttributeUsage(AllowMultiple = false)
58+
|> Option.map (fun a -> a :?> IUnionConverterOptions)
2759
}
2860
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) |]
92+
93+
type UnionConverter<'T>() =
94+
inherit Serialization.JsonConverter<'T>()
95+
96+
static let defaultConverterOptions = { discriminator = "case"; catchAllCase = None } :> IUnionConverterOptions
97+
98+
let getOptions union = defaultArg union.options defaultConverterOptions
99+
100+
override _.CanConvert(t : Type) = t = typeof<'T> && Union.isUnion t
101+
102+
override _.Write(writer, value, options) =
103+
let value = box value
104+
let union = Union.getUnion typeof<'T>
105+
let unionOptions = getOptions union
106+
let tag = union.tagReader value
107+
let case = union.cases.[tag]
108+
let fieldValues = union.fieldReader.[tag] value
109+
let fieldInfos = case.GetFields()
110+
111+
writer.WriteStartObject()
112+
writer.WritePropertyName(unionOptions.Discriminator)
113+
writer.WriteStringValue(case.Name)
114+
for fieldInfo, fieldValue in Seq.zip fieldInfos fieldValues do
115+
if fieldValue <> null || options.DefaultIgnoreCondition <> Serialization.JsonIgnoreCondition.Always then
116+
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
119+
for prop in element.EnumerateObject() do
120+
prop.WriteTo writer
121+
else
122+
writer.WritePropertyName(fieldInfo.Name)
123+
element.WriteTo writer
124+
writer.WriteEndObject()
125+
126+
override _.Read(reader, t : Type, options) =
127+
if reader.TokenType <> JsonTokenType.StartObject then
128+
sprintf "Unexpected token when reading Union: %O" reader.TokenType |> JsonException |> raise
129+
use document = JsonDocument.ParseValue &reader
130+
let union = Union.getUnion typeof<'T>
131+
let unionOptions = getOptions union
132+
let element = document.RootElement
133+
134+
let targetCaseIndex =
135+
let inputCaseNameValue = element.GetProperty unionOptions.Discriminator |> string
136+
let findCaseNamed x = union.cases |> Array.tryFindIndex (fun case -> case.Name = x)
137+
match findCaseNamed inputCaseNameValue, unionOptions.CatchAllCase with
138+
| None, None ->
139+
sprintf "No case defined for '%s', and no catchAllCase nominated for '%s' on type '%s'"
140+
inputCaseNameValue typeof<UnionConverter<_>>.Name t.FullName |> invalidOp
141+
| Some foundIndex, _ -> foundIndex
142+
| None, Some catchAllCaseName ->
143+
match findCaseNamed catchAllCaseName with
144+
| None ->
145+
sprintf "No case defined for '%s', nominated catchAllCase: '%s' not found in type '%s'"
146+
inputCaseNameValue catchAllCaseName t.FullName |> invalidOp
147+
| Some foundIndex -> foundIndex
148+
149+
let targetCaseFields, targetCaseCtor = union.cases.[targetCaseIndex].GetFields(), union.caseConstructor.[targetCaseIndex]
150+
targetCaseCtor (Union.mapTargetCaseArgs element options targetCaseFields) :?> 'T

tests/FsCodec.NewtonsoftJson.Tests/Fixtures.fs

+8
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
#if SYSTEM_TEXT_JSON
2+
module FsCodec.SystemTextJson.Tests.Fixtures
3+
4+
open FsCodec.SystemTextJson // JsonIsomorphism
5+
open System.Text.Json.Serialization // JsonConverter
6+
#else
17
module FsCodec.NewtonsoftJson.Tests.Fixtures
28

39
open FsCodec.NewtonsoftJson // JsonIsomorphism
410
open Newtonsoft.Json // JsonConverter
11+
#endif
12+
513
open System
614
open System.Runtime.Serialization
715

0 commit comments

Comments
 (0)