Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Port UnionConverter to STJ re #43 #59

Merged
merged 67 commits into from
Jan 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
5616e45
Initial implementation
ylibrach Mar 5, 2020
4e2f9fc
Add FsCodec.Tests
bartelink Mar 5, 2020
7f67669
Tidy/update test dependencies
bartelink Mar 5, 2020
080e207
Add FsCodec.SystemTextJson
bartelink Mar 5, 2020
3bd0411
Add STJ Codec
bartelink Mar 5, 2020
c33f654
Port JsonIsomorphism from Newtonsoft variant (#40)
bartelink Mar 18, 2020
ffc7b5f
Port TypeSafeEnum and Converter to System.Text.Json (#41)
bartelink Mar 18, 2020
32a538f
Extend README to cover FsCodec.STJ
bartelink Apr 14, 2020
58a30ec
Default to not over-escaping in SystemTextJson re #44 (#45)
bartelink Apr 14, 2020
1366dc3
Document Some null handling wart (#39)
bartelink Apr 14, 2020
b4cf917
Generalize FsCodec.SystemTextJson.TypeSafeEnum.tryParseT
bartelink Apr 14, 2020
e45ef96
Add STJ Tutorial.fsx
bartelink Apr 14, 2020
e6a33d1
Extract Box.Codec into FsCodec.Box package (#46)
bartelink Apr 14, 2020
b619c04
Document JsonRecordConverter
bartelink Apr 15, 2020
f187c9d
Document converters philosophy (#47)
bartelink Apr 15, 2020
b3b2e20
Fix porting typo re meta
bartelink Apr 16, 2020
a56ed4d
STJ: Add ToByteArrayCodec/ToJsonElementCodec shims (#48)
bartelink Apr 17, 2020
a7c80f4
Cleanup #ifdefs
bartelink Apr 17, 2020
f78df5d
wip
bartelink Mar 12, 2020
1ac36de
Makes things compile
NickDarvey Mar 30, 2020
f225bf5
Ports tests to STJ
NickDarvey Mar 30, 2020
1d7850a
Answered my own question
NickDarvey Mar 30, 2020
1c37108
Gets serializing working at least a lil
NickDarvey Mar 31, 2020
6febb49
Moves converters into FsCodec.SystemTextJson.Converters namespace for…
NickDarvey Mar 31, 2020
b512640
Serialization passes when converters are manually specified
NickDarvey Mar 31, 2020
c237c58
Fixes attribute usage so you don't have to specify them in options.
NickDarvey Apr 8, 2020
8ae5deb
Handles missing properties
NickDarvey Apr 13, 2020
92e5f36
Enables unmatched case handling tests
NickDarvey Apr 13, 2020
3b138f2
Adds `JsonUnionConverterOptionsAttribute` so users can specify their …
NickDarvey Apr 15, 2020
95ac2da
Removes FsCodec.SystemTextJson.Converters namespace
NickDarvey Apr 15, 2020
104dbaf
Removes missing member handling tests as it's no longer supported
NickDarvey Apr 15, 2020
a5cfd76
Adds JsonElement test
NickDarvey Apr 15, 2020
a61af4f
Adds test for struct-based DUs
NickDarvey Apr 15, 2020
a5ad973
Removes UnionConverter JsonConverterFactory so each DU requires a spe…
NickDarvey Apr 16, 2020
824a903
Explicitly declares union when registering converter
NickDarvey Apr 16, 2020
ab45ca3
Reduces greediness
NickDarvey Apr 17, 2020
aeb3363
Moves small things on to one line, fixes incorrect comments
NickDarvey Apr 25, 2020
821bbfb
Merge remote-tracking branch 'origin/master' into nickdarvey-unioncon…
bartelink Dec 9, 2020
28d05e6
Remove placeholder tests
bartelink Dec 9, 2020
6f3d022
Sync test suites across serializers
bartelink Dec 9, 2020
fb201fe
Make Fixtures DRY for clarity
bartelink Dec 9, 2020
80cbdfd
Level impl with other converters
bartelink Dec 10, 2020
889de64
f remove RecordConverter
bartelink Dec 10, 2020
3ff1d48
Merge branch 'master' into nickdarvey-unionconverter-stj
bartelink Dec 10, 2020
9b06e56
Remove erroneous diffs from merge
bartelink Dec 10, 2020
5882acc
Merge branch 'master' into nickdarvey-unionconverter-stj
bartelink Dec 10, 2020
85983e3
WIP cherrypicking nesting support from #52
bartelink Dec 10, 2020
ebaba63
Converge Newtonsoft and STJ UnionEncoder tests
bartelink Dec 18, 2020
53ec0de
Undo V5 upgrade
bartelink Dec 18, 2020
ba605ab
Diff reduction
bartelink Dec 18, 2020
b45fefc
Merge branch 'master' into nickdarvey-unionconverter-stj
bartelink Sep 9, 2021
927271c
Update to STJ V6
bartelink Sep 9, 2021
11894b1
Fix xmldoc
bartelink Sep 9, 2021
cfe0b4f
Port/workaround IgnoreNullValues changes
bartelink Sep 9, 2021
003ec1a
Merge branch 'master' into nickdarvey-unionconverter-stj
bartelink Jan 3, 2022
a107eb5
Update to STJ 6.0.1
bartelink Jan 3, 2022
2481fb6
Fix UnionConverter
bartelink Jan 3, 2022
5be4848
Tidy
bartelink Jan 3, 2022
3afb011
Tidy
bartelink Jan 3, 2022
529191d
Remove unnecessary shimming
bartelink Jan 3, 2022
2777484
Update TypeShape to 10.0.0
bartelink Jan 3, 2022
c948dcb
Remove references to OptionConverter from STJ impl
bartelink Jan 3, 2022
98599ee
Declare work complete
bartelink Jan 3, 2022
4fe109a
Generalize cases
bartelink Jan 3, 2022
83cf8c1
Merge remote-tracking branch 'origin/master' into nickdarvey-unioncon…
bartelink Jan 3, 2022
6234435
Revert accidental change
bartelink Jan 3, 2022
6e9a210
Finalize changelog
bartelink Jan 4, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ The `Unreleased` section name is replaced by the expected version of next releas
## [Unreleased]

### Added

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

### Changed

- `SystemTextJson`: Target `System.Text.Json` v `6.0.1`, `TypeShape` v `10.0.0` [#68](https://github.com/jet/FsCodec/pull/68)
Expand Down
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,6 @@ The components within this repository are delivered as multi-targeted Nuget pack
- [![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.
- [depends](https://www.fuget.org/packages/FsCodec.SystemTextJson) on `FsCodec`, `System.Text.Json >= 6.0.1`, `TypeShape >= 10`

Deltas in behavior/functionality vs `FsCodec.NewtonsoftJson`:

1. [`UnionConverter` is WIP](https://github.com/jet/FsCodec/pull/43)

# Features: `FsCodec`

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.
Expand Down
122 changes: 122 additions & 0 deletions src/FsCodec.SystemTextJson/UnionConverter.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@

open FSharp.Reflection
open System
open System.Reflection
open System.Text.Json

type IUnionConverterOptions =
abstract member Discriminator : string with get
abstract member CatchAllCase : string option with get

/// Use this attribute in combination with a JsonConverter/UnionConverter attribute to specify
/// your own name for a discriminator and/or a catch-all case for a specific discriminated union.
/// If this attribute is set, its values take precedence over the values set on the converter via its constructor.
/// Example: <c>[<JsonConverter(typeof<UnionConverter<T>>); JsonUnionConverterOptions("type")>]</c>
[<AttributeUsage(AttributeTargets.Class ||| AttributeTargets.Struct, AllowMultiple = false, Inherited = false)>]
type JsonUnionConverterOptionsAttribute(discriminator : string) =
inherit Attribute()
member val CatchAllCase : string = null with get, set
interface IUnionConverterOptions with
member _.Discriminator = discriminator
member x.CatchAllCase = Option.ofObj x.CatchAllCase

type UnionConverterOptions =
{
discriminator : string
catchAllCase : string option
}
interface IUnionConverterOptions with
member x.Discriminator = x.discriminator
member x.CatchAllCase = x.catchAllCase

[<NoComparison; NoEquality>]
type private Union =
Expand All @@ -10,6 +37,7 @@ type private Union =
tagReader : obj -> int
fieldReader : (obj -> obj[])[]
caseConstructor : (obj[] -> obj)[]
options : IUnionConverterOptions option
}

module private Union =
Expand All @@ -24,5 +52,99 @@ module private Union =
tagReader = FSharpValue.PreComputeUnionTagReader(t, true)
fieldReader = cases |> Array.map (fun c -> FSharpValue.PreComputeUnionReader(c, true))
caseConstructor = cases |> Array.map (fun c -> FSharpValue.PreComputeUnionConstructor(c, true))
options =
t.GetCustomAttributes(typeof<JsonUnionConverterOptionsAttribute>, false)
|> Array.tryHead // AttributeUsage(AllowMultiple = false)
|> Option.map (fun a -> a :?> IUnionConverterOptions)
}
let getUnion : Type -> Union = memoize createUnion

/// Parallels F# behavior wrt how it generates a DU's underlying .NET Type
let inline isInlinedIntoUnionItem (t : Type) =
t = typeof<string>
|| (t.IsValueType && t <> typeof<JsonElement>)
|| t.IsArray
|| (t.IsGenericType
&& (typedefof<Option<_>> = t.GetGenericTypeDefinition()
|| t.GetGenericTypeDefinition().IsValueType)) // Nullable<T>

let typeHasJsonConverterAttribute_ (t : Type) = t.IsDefined(typeof<Serialization.JsonConverterAttribute>(*, false*))
let typeHasJsonConverterAttribute = memoize typeHasJsonConverterAttribute_
let typeIsUnionWithConverterAttribute = memoize (fun (t : Type) -> isUnion t && typeHasJsonConverterAttribute_ t)

let propTypeRequiresConstruction (propertyType : Type) =
not (isInlinedIntoUnionItem propertyType)
&& not (typeHasJsonConverterAttribute propertyType)

/// Prepare arguments for the Case class ctor based on the kind of case and how F# maps that to a Type
/// and/or whether we need to defer to System.Text.Json
let mapTargetCaseArgs (element : JsonElement) (options : JsonSerializerOptions) (props : PropertyInfo[]) : obj [] =
match props with
| [| singleCaseArg |] when propTypeRequiresConstruction singleCaseArg.PropertyType ->
[| JsonSerializer.Deserialize(element, singleCaseArg.PropertyType, options) |]
| multipleFieldsInCustomCaseType ->
[| for fi in multipleFieldsInCustomCaseType ->
match element.TryGetProperty fi.Name with
| false, _ when fi.PropertyType.IsValueType -> Activator.CreateInstance fi.PropertyType
| false, _ -> null
| true, el when el.ValueKind = JsonValueKind.Null -> null
| true, el -> JsonSerializer.Deserialize(el, fi.PropertyType, options) |]

type UnionConverter<'T>() =
inherit Serialization.JsonConverter<'T>()

static let defaultConverterOptions = { discriminator = "case"; catchAllCase = None } :> IUnionConverterOptions

let getOptions union = defaultArg union.options defaultConverterOptions

override _.CanConvert(t : Type) = t = typeof<'T> && Union.isUnion t

override _.Write(writer, value, options) =
let value = box value
let union = Union.getUnion typeof<'T>
let unionOptions = getOptions union
let tag = union.tagReader value
let case = union.cases.[tag]
let fieldValues = union.fieldReader.[tag] value
let fieldInfos = case.GetFields()

writer.WriteStartObject()
writer.WritePropertyName(unionOptions.Discriminator)
writer.WriteStringValue(case.Name)
for fieldInfo, fieldValue in Seq.zip fieldInfos fieldValues do
if fieldValue <> null || options.DefaultIgnoreCondition <> Serialization.JsonIgnoreCondition.Always then
let element = JsonSerializer.SerializeToElement(fieldValue, fieldInfo.PropertyType, options)
if fieldInfos.Length = 1 && element.ValueKind = JsonValueKind.Object && not (Union.typeIsUnionWithConverterAttribute fieldInfo.PropertyType) then
// flatten the object properties into the same one as the discriminator
for prop in element.EnumerateObject() do
prop.WriteTo writer
else
writer.WritePropertyName(fieldInfo.Name)
element.WriteTo writer
writer.WriteEndObject()

override _.Read(reader, t : Type, options) =
if reader.TokenType <> JsonTokenType.StartObject then
sprintf "Unexpected token when reading Union: %O" reader.TokenType |> JsonException |> raise
use document = JsonDocument.ParseValue &reader
let union = Union.getUnion typeof<'T>
let unionOptions = getOptions union
let element = document.RootElement

let targetCaseIndex =
let inputCaseNameValue = element.GetProperty unionOptions.Discriminator |> string
let findCaseNamed x = union.cases |> Array.tryFindIndex (fun case -> case.Name = x)
match findCaseNamed inputCaseNameValue, unionOptions.CatchAllCase with
| None, None ->
sprintf "No case defined for '%s', and no catchAllCase nominated for '%s' on type '%s'"
inputCaseNameValue typeof<UnionConverter<_>>.Name t.FullName |> invalidOp
| Some foundIndex, _ -> foundIndex
| None, Some catchAllCaseName ->
match findCaseNamed catchAllCaseName with
| None ->
sprintf "No case defined for '%s', nominated catchAllCase: '%s' not found in type '%s'"
inputCaseNameValue catchAllCaseName t.FullName |> invalidOp
| Some foundIndex -> foundIndex

let targetCaseFields, targetCaseCtor = union.cases.[targetCaseIndex].GetFields(), union.caseConstructor.[targetCaseIndex]
targetCaseCtor (Union.mapTargetCaseArgs element options targetCaseFields) :?> 'T
8 changes: 8 additions & 0 deletions tests/FsCodec.NewtonsoftJson.Tests/Fixtures.fs
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
#if SYSTEM_TEXT_JSON
module FsCodec.SystemTextJson.Tests.Fixtures

open FsCodec.SystemTextJson // JsonIsomorphism
open System.Text.Json.Serialization // JsonConverter
#else
module FsCodec.NewtonsoftJson.Tests.Fixtures

open FsCodec.NewtonsoftJson // JsonIsomorphism
open Newtonsoft.Json // JsonConverter
#endif

open System
open System.Runtime.Serialization

Expand Down
Loading