Skip to content

Commit

Permalink
Port JsonIsomorphism from Newtonsoft variant
Browse files Browse the repository at this point in the history
  • Loading branch information
bartelink committed Mar 8, 2020
1 parent 3bd0411 commit a270105
Show file tree
Hide file tree
Showing 6 changed files with 134 additions and 8 deletions.
1 change: 1 addition & 0 deletions src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<Compile Include="Options.fs" />
<Compile Include="Codec.fs" />
<Compile Include="Serdes.fs" />
<Compile Include="Pickler.fs" />
</ItemGroup>

<ItemGroup>
Expand Down
12 changes: 7 additions & 5 deletions src/FsCodec.SystemTextJson/JsonRecordConverter.fs
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,13 @@ type JsonRecordConverter<'T> (options: JsonSerializerOptions) =
|> Array.tryHead
|> Option.map (fun attr -> attr :?> JsonConverterAttribute)
|> Option.bind (fun attr ->
let baseConverter = attr.CreateConverter(f.PropertyType)

if baseConverter |> isNull then
failwithf "Field %s is decorated with a JsonConverter attribute, but it does not implement a CreateConverter method." f.Name

let baseConverter =
match attr.CreateConverter(f.PropertyType) with
| null ->
match Activator.CreateInstance(attr.ConverterType) with
| :? JsonConverter as x -> x
| _ -> failwithf "Field %s is decorated with a JsonConverter attribute that does not implement a CreateConverter method or refer to a valid Converter." f.Name
| created -> created
if baseConverter.CanConvert(f.PropertyType) then
let converterType = typedefof<RecordFieldConverter<_>>.MakeGenericType(f.PropertyType)
let converter = Activator.CreateInstance(converterType) :?> IRecordFieldConverter
Expand Down
6 changes: 3 additions & 3 deletions src/FsCodec.SystemTextJson/Options.fs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ open System.Text.Json.Serialization

type Options private () =

static let defaultConverters : JsonConverterFactory[] =
static let defaultConverters : JsonConverter[] =
[| Converters.JsonOptionConverter()
Converters.JsonRecordConverter() |]

/// Creates a default set of serializer options used by Json serialization. When used with no args, same as `JsonSerializerOptions()`
static member CreateDefault
( [<Optional; ParamArray>] converters : JsonConverterFactory[],
( [<Optional; ParamArray>] converters : JsonConverter[],
/// Use multi-line, indented formatting when serializing JSON; defaults to false.
[<Optional; DefaultParameterValue(null)>] ?indent : bool,
/// Render idiomatic camelCase for PascalCase items by using `PropertyNamingPolicy = CamelCase`. Defaults to false.
Expand All @@ -37,7 +37,7 @@ type Options private () =
/// Everything else is as per CreateDefault:- i.e. emit nulls instead of omitting fields, no indenting, no camelCase conversion
static member Create
( /// List of converters to apply. Implicit [JsonOptionConverter(); JsonRecordConverter()] will be prepended and/or be used as a default
[<Optional; ParamArray>] converters : JsonConverterFactory[],
[<Optional; ParamArray>] converters : JsonConverter[],
/// Use multi-line, indented formatting when serializing JSON; defaults to false.
[<Optional; DefaultParameterValue(null)>] ?indent : bool,
/// Render idiomatic camelCase for PascalCase items by using `PropertyNamingPolicy = CamelCase`.
Expand Down
60 changes: 60 additions & 0 deletions src/FsCodec.SystemTextJson/Pickler.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
namespace FsCodec.SystemTextJson

open System
open System.Text.Json

[<AutoOpen>]
module private Prelude =
/// Provides a thread-safe memoization wrapper for supplied function
let memoize : ('T -> 'S) -> 'T -> 'S =
fun f ->
let cache = System.Collections.Concurrent.ConcurrentDictionary<'T, 'S>()
fun t -> cache.GetOrAdd(t, f)

[<AbstractClass>]
type JsonPickler<'T>() =
inherit Serialization.JsonConverter<'T>()

static let isMatchingType =
let rec isMatching (ts : Type list) =
match ts with
| [] -> false
| t :: _ when t = typeof<'T> -> true
| t :: tl ->
let tail =
[ match t.BaseType with null -> () | bt -> yield bt
yield! t.GetInterfaces()
yield! tl ]

isMatching tail

memoize (fun t -> isMatching [t])

abstract Read : reader: byref<Utf8JsonReader> * options: JsonSerializerOptions -> 'T

override __.CanConvert t = isMatchingType t

override __.Read(reader, _ : Type, opts) =
__.Read(&reader, opts)

/// Json Converter that serializes based on an isomorphic type
[<AbstractClass>]
type JsonIsomorphism<'T, 'U>(?targetPickler : JsonPickler<'U>) =
inherit JsonPickler<'T>()

abstract Pickle : 'T -> 'U
abstract UnPickle : 'U -> 'T

override __.Write(writer, source : 'T, options) =
let target = __.Pickle source
match targetPickler with
| None -> JsonSerializer.Serialize(writer, target, options)
| Some p -> p.Write(writer, target, options)

override __.Read(reader, options) =
let target =
match targetPickler with
| None -> JsonSerializer.Deserialize<'U>(&reader,options)
| Some p -> p.Read(&reader, options)

__.UnPickle target
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

<ItemGroup>
<Compile Include="SerdesTests.fs" />
<Compile Include="PicklerTests.fs" />
<Compile Include="CodecTests.fs" />
</ItemGroup>

Expand Down
62 changes: 62 additions & 0 deletions tests/FsCodec.SystemTextJson.Tests/PicklerTests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
module FsCodec.SystemTextJson.Tests.PicklerTests

open FsCodec.SystemTextJson
open FsCodec.SystemTextJson.Converters
open System
open System.Text.Json
open Swensen.Unquote
open Xunit

// NB Feel free to ignore this opinion and copy the 4 lines into your own globals - the pinning test will remain here
/// <summary>
/// Renders Guids without dashes.
/// </summary>
/// <remarks>
/// Can work correctly as a global converter, as some codebases do for historical reasons
/// Could arguably be usable as base class for various converters, including the above.
/// However, both of these usage patterns and variants thereof are not recommended for new types.
/// In general, the philosophy is that, beyond the Pickler base types, an identity type should consist of explicit
/// code as much as possible, and global converters really have to earn their keep - magic starts with -100 points.
/// </remarks>
type GuidConverter() =
inherit JsonIsomorphism<Guid, string>()
override __.Pickle g = g.ToString "N"
override __.UnPickle g = Guid.Parse g

type WithEmbeddedGuid = { a: string; [<Serialization.JsonConverter(typeof<GuidConverter>)>] b: Guid }

type Configs() as this =
inherit TheoryData<JsonSerializerOptions>()
do this.Add(Options.CreateDefault(JsonRecordConverter())) // validate it works with minimal converters
this.Add(Options.Create()) // Flush out clashes with standard converter set
this.Add(Options.Create(GuidConverter())) // and a global registration does not conflict

let [<Theory; ClassData(typeof<Configs>)>] ``Tagging with GuidConverter roundtrips`` (options : JsonSerializerOptions) =
let value = { a = "testing"; b = Guid.Empty }

let result = Serdes.Serialize(value, options)

test <@ """{"a":"testing","b":"00000000000000000000000000000000"}""" = result @>

let des = Serdes.Deserialize(result, options)
test <@ value = des @>

let [<Fact>] ``Global GuidConverter roundtrips`` () =
let value = Guid.Empty

let defaultHandlingHasDashes = Serdes.Serialize value

let optionsWithConverter = Options.Create(GuidConverter())
let resNoDashes = Serdes.Serialize(value, optionsWithConverter)

test <@ "\"00000000-0000-0000-0000-000000000000\"" = defaultHandlingHasDashes
&& "\"00000000000000000000000000000000\"" = resNoDashes @>

// Non-dashed is not accepted by default handling in STJ (Newtonsoft does accept it)
raises<exn> <@ Serdes.Deserialize<Guid> resNoDashes @>

// With the converter, things roundtrip either way
for result in [defaultHandlingHasDashes; resNoDashes] do
let des = Serdes.Deserialize(result, optionsWithConverter)
test <@ value= des @>

0 comments on commit a270105

Please sign in to comment.