Skip to content

Commit

Permalink
Make Serdes stateful (#70)
Browse files Browse the repository at this point in the history
  • Loading branch information
bartelink authored Jan 5, 2022
1 parent 82a3390 commit bed22c3
Show file tree
Hide file tree
Showing 16 changed files with 198 additions and 136 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ The `Unreleased` section name is replaced by the expected version of next releas

### Added
### Changed

- `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)
- `Serdes.DefaultSettings`: Updated [README.md ASP.NET integration advice](https://github.com/jet/FsCodec#aspnetstj) to reflect minor knock-on effect [#70](https://github.com/jet/FsCodec/pull/70)

### Removed
### Fixed

Expand Down
1 change: 1 addition & 0 deletions FsCodec.sln
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".project", ".project", "{1D
README.md = README.md
SECURITY.md = SECURITY.md
CHANGELOG.md = CHANGELOG.md
FsCodec.sln.DotSettings.user = FsCodec.sln.DotSettings.user
EndProjectSection
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec", "src\FsCodec\FsCodec.fsproj", "{9D2A9566-9C80-4AF3-A487-76A9FE8CBE64}"
Expand Down
2 changes: 2 additions & 0 deletions FsCodec.sln.DotSettings
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=serdes/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
31 changes: 18 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,10 @@ The respective concrete Codec packages include relevant `Converter`/`JsonConvert

## `Serdes`

[`FsCodec.NewtonsoftJson.Serdes`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/Serdes.fs#L7) provides light wrappers over `JsonConvert.(Des|S)erializeObject` that utilize the serialization profile defined by `Settings/Options.Create` (above). Methods:
[`FsCodec.SystemTextJson/NewtonsoftJson.Serdes`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.SystemTextJson/Serdes.fs#L7) provides light wrappers over `(JsonConvert|JsonSerializer).(Des|S)erialize(Object)?` based on an explicitly supplied serialization profile created by `Settings/Options.Create` (above). This enables one to smoothly switch between `System.Text.Json` vs `Newtonsoft.Json` serializers with minimal application code changes, while also ensuring consistent and correct options get applied in each case. Methods:
- `Serialize<T>`: serializes an object per its type using the settings defined in `Settings/Options.Create`
- `Deserialize<T>`: deserializes an object per its type using the settings defined in `Settings/Options.Create`
- `DefaultSettings` / `DefaultOptions`: Allows one to access a global static instance of the `JsonSerializerSettings`/`JsonSerializerOptions` used by the default profile.
- `Options`: Allows one to access the `JsonSerializerSettings`/`JsonSerializerOptions` used by this instance.

# Usage of Converters with ASP.NET Core

Expand All @@ -137,23 +137,26 @@ If you follow the policies covered in the rest of the documentation here, your D
## ASP.NET Core with `Newtonsoft.Json`
Hence the following represents the recommended default policy:-

/// Define a Serdes instance with a given policy somewhere (globally if you need to do explicit JSON generation)
let serdes = Settings.Create() |> Serdes

services.AddMvc(fun options -> ...
).AddNewtonsoftJson(fun options ->
FsCodec.NewtonsoftJson.Serdes.DefaultSettings.Converters
|> Seq.iter options.SerializerSettings.Converters.Add
serdes.Options.Converters |> Seq.iter options.SerializerSettings.Converters.Add
) |> ignore

This adds all the converters used by the default `Serdes` mechanism (currently only `FsCodec.NewtonsoftJson.OptionConverter`), and add them to any imposed by other configuration logic.
This adds all the converters used by the `serdes` serialization/deserialization policy (currently only `FsCodec.NewtonsoftJson.OptionConverter`) into the equivalent managed by ASP.NET.

<a name="aspnetstj"></a>
## ASP.NET Core with `System.Text.Json`

The equivalent for the native `System.Text.Json` looks like this:

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

services.AddMvc(fun options -> ...
).AddJsonOptions(fun options ->
FsCodec.SystemTextJson.Serdes.DefaultOptions.Converters
|> Seq.iter options.JsonSerializerOptions.Converters.Add
serdes.Options.Converters |> Seq.iter options.JsonSerializerOptions.Converters.Add
) |> ignore

_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._
Expand All @@ -165,7 +168,7 @@ There's a test playground in [tests/FsCodec.NewtonsoftJson.Tests/Examples.fsx](t
There's an equivalent of that for `FsCodec.SystemTextJson`: [tests/FsCodec.SystemTextJson.Tests/Examples.fsx](tests/FsCodec.SystemTextJson.Tests/Examples.fsx).

<a name="contracts"></a>
### Examples of using `Settings` and `Serdes` to define a contract
### Examples of using `Serdes` to define a contract

In a contract assembly used as a way to supply types as part of a client library, one way of encapsulating the conversion rules that need to be applied is as follows:

Expand All @@ -176,10 +179,12 @@ The minimal code needed to define helpers to consistently roundtrip where one on
```fsharp
module Contract =
type Item = { value : string option }
/// Settings to be used within this contract (opinionated ones compared to just using JsonConvert.SerializeObject / DeserializeObject)
let private serdes = FsCodec.NewtonsoftJson.Settings() |> FsCodec.NewtonsoftJson.Serdes
// implies default settings from Settings.Create(), which includes OptionConverter
let serialize (x : Item) : string = FsCodec.NewtonsoftJson.Serdes.Serialize x
let serialize (x : Item) : string = serdes.Serialize x
// implies default settings from Settings.Create(), which includes OptionConverter
let deserialize (json : string) = FsCodec.NewtonsoftJson.Serdes.Deserialize json
let deserialize (json : string) = serdes.Deserialize json
```

#### More advanced case necessitating a custom converter
Expand All @@ -190,9 +195,9 @@ While it's hard to justify the wrapping in the previous case, this illustrates h
module Contract =
type Item = { value : string option; other : TypeThatRequiresMyCustomConverter }
/// Settings to be used within this contract
let settings = FsCodec.NewtonsoftJson.Settings.Create(converters = [| MyCustomConverter() |])
let serialize (x : Item) = FsCodec.NewtonsoftJson.Serdes.Serialize(x,settings)
let deserialize (json : string) : Item = FsCodec.NewtonsoftJson.Serdes.Deserialize(json,settings)
let private serdes = FsCodec.NewtonsoftJson.Settings.Create(converters = [| MyCustomConverter() |]) |> FsCodec.NewtonsoftJson.Serdes
let serialize (x : Item) = serdes.Serialize x
let deserialize (json : string) : Item = serdes.Deserialize json
```

## Encoding and conversion of F# types
Expand Down
28 changes: 18 additions & 10 deletions src/FsCodec.NewtonsoftJson/Serdes.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,32 @@ namespace FsCodec.NewtonsoftJson
open Newtonsoft.Json
open System.Runtime.InteropServices

/// <summary>Serializes to/from strings using the settings arising from a call to <c>Settings.Create()</c></summary>
type Serdes private () =
/// Serializes to/from strings using the supplied Settings
type Serdes(options : JsonSerializerSettings) =

static let defaultSettings = lazy Settings.Create()
static let indentSettings = lazy Settings.Create(indent = true)
/// <summary>The <c>JsonSerializerSettings</c> used by this instance.</summary>
member _.Options : JsonSerializerSettings = options

/// <summary>Yields the settings used by <c>Serdes</c> when no <c>settings</c> are supplied.</summary>
static member DefaultSettings : JsonSerializerSettings = defaultSettings.Value
/// Serializes given value to a JSON string.
member _.Serialize<'T>(value : 'T) =
JsonConvert.SerializeObject(value, options)

/// Deserializes value of given type from JSON string.
member x.Deserialize<'T>(json : string) : 'T =
JsonConvert.DeserializeObject<'T>(json, options)

/// Serializes given value to a JSON string.
[<System.Obsolete "Please use non-static Serdes instead">]
static member Serialize<'T>
( /// Value to serialize.
value : 'T,
/// Use indentation when serializing JSON. Defaults to false.
[<Optional; DefaultParameterValue false>] ?indent : bool) : string =
let settings = (if defaultArg indent false then indentSettings else defaultSettings).Value
Serdes.Serialize<'T>(value, settings)
let options = (if indent = Some true then Settings.Create(indent = true) else Settings.Create())
JsonConvert.SerializeObject(value, options)

/// Serializes given value to a JSON string with custom settings
/// Serializes given value to a JSON string with custom options
[<System.Obsolete "Please use non-static Serdes instead">]
static member Serialize<'T>
( /// Value to serialize.
value : 'T,
Expand All @@ -30,10 +37,11 @@ type Serdes private () =
JsonConvert.SerializeObject(value, settings)

/// Deserializes value of given type from JSON string.
[<System.Obsolete "Please use non-static Serdes instead">]
static member Deserialize<'T>
( /// Json string to deserialize.
json : string,
/// Settings to use (defaults to Settings.Create() profile)
[<Optional; DefaultParameterValue null>] ?settings : JsonSerializerSettings) : 'T =
let settings = match settings with None -> defaultSettings.Value | Some x -> x
let settings = match settings with Some x -> x | None -> Settings.Create()
JsonConvert.DeserializeObject<'T>(json, settings)
5 changes: 4 additions & 1 deletion src/FsCodec.SystemTextJson/Options.fs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ type Options private () =
/// Ignore null values in input data, don't render fields with null values; defaults to `false`.
[<Optional; DefaultParameterValue(null)>] ?ignoreNulls : bool,
/// Drop escaping of HTML-sensitive characters. defaults to `true`.
[<Optional; DefaultParameterValue(null)>] ?unsafeRelaxedJsonEscaping : bool) =
[<Optional; DefaultParameterValue(null)>] ?unsafeRelaxedJsonEscaping : bool,
/// <summary>Apply convention-based Union conversion using <c>TypeSafeEnumConverter</c> if possible, or <c>UnionEncoder</c> for all Discriminated Unions.
/// defaults to <c>false</c>.</summary>
[<Optional; DefaultParameterValue(null)>] ?autoUnion : bool) =

Options.CreateDefault(
converters = converters,
Expand Down
26 changes: 17 additions & 9 deletions src/FsCodec.SystemTextJson/Serdes.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,32 @@ namespace FsCodec.SystemTextJson
open System.Runtime.InteropServices
open System.Text.Json

/// Serializes to/from strings using the Options arising from a call to <c>Options.Create()</c>
type Serdes private () =
/// Serializes to/from strings using the supplied Options
type Serdes(options : JsonSerializerOptions) =

static let defaultOptions = lazy Options.Create()
static let indentOptions = lazy Options.Create(indent = true)
/// <summary>The <c>JsonSerializerOptions</c> used by this instance.</summary>
member _.Options : JsonSerializerOptions = options

/// Yields the settings used by <c>Serdes</c> when no <c>options</c> are supplied.
static member DefaultOptions : JsonSerializerOptions = defaultOptions.Value
/// Serializes given value to a JSON string.
member _.Serialize<'T>(value : 'T) =
JsonSerializer.Serialize<'T>(value, options)

/// Deserializes value of given type from JSON string.
member x.Deserialize<'T>(json : string) : 'T =
JsonSerializer.Deserialize<'T>(json, options)

/// Serializes given value to a JSON string.
[<System.Obsolete "Please use non-static Serdes instead">]
static member Serialize<'T>
( /// Value to serialize.
value : 'T,
/// Use indentation when serializing JSON. Defaults to false.
[<Optional; DefaultParameterValue false>] ?indent : bool) : string =
let options = (if defaultArg indent false then indentOptions else defaultOptions).Value
Serdes.Serialize<'T>(value, options)
let options = (if indent = Some true then Options.Create(indent = true) else Options.Create())
JsonSerializer.Serialize<'T>(value, options)

/// Serializes given value to a JSON string with custom options
[<System.Obsolete "Please use non-static Serdes instead">]
static member Serialize<'T>
( /// Value to serialize.
value : 'T,
Expand All @@ -30,10 +37,11 @@ type Serdes private () =
JsonSerializer.Serialize<'T>(value, options)

/// Deserializes value of given type from JSON string.
[<System.Obsolete "Please use non-static Serdes instead">]
static member Deserialize<'T>
( /// Json string to deserialize.
json : string,
/// Options to use (defaults to Options.Create() profile)
[<Optional; DefaultParameterValue null>] ?options : JsonSerializerOptions) : 'T =
let settings = match options with None -> defaultOptions.Value | Some x -> x
let settings = options |> Option.defaultWith Options.Create
JsonSerializer.Deserialize<'T>(json, settings)
34 changes: 23 additions & 11 deletions tests/FsCodec.NewtonsoftJson.Tests/Examples.fsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
// Compile the fsproj by either a) right-clicking or b) typing
// dotnet build tests/FsCodec.NewtonsoftJson.Tests before attempting to send this to FSI with Alt-Enter

#if USE_LOCAL_BUILD
#I "bin/Debug/net5.0"
#r "FsCodec.dll"
#r "Newtonsoft.Json.dll"
#r "FsCodec.NewtonsoftJson.dll"
#r "TypeShape.dll"
#r "FSharp.UMX.dll"
#r "Serilog.dll"
#r "Serilog.Sinks.Console.dll"
#else
#r "nuget: FsCodec.NewtonsoftJson"
#r "nuget: Serilog.Sinks.Console"
#endif

open FsCodec.NewtonsoftJson
open Newtonsoft.Json
Expand All @@ -11,10 +22,10 @@ open System
module Contract =

type Item = { value : string option }
// implies default settings from Settings.Create(), which includes OptionConverter
let serialize (x : Item) : string = FsCodec.NewtonsoftJson.Serdes.Serialize x
// implies default settings from Settings.Create(), which includes OptionConverter
let deserialize (json : string) = FsCodec.NewtonsoftJson.Serdes.Deserialize json
// implies an OptionConverter will be applied
let private serdes = FsCodec.NewtonsoftJson.Settings.Create() |> FsCodec.NewtonsoftJson.Serdes
let serialize (x : Item) : string = serdes.Serialize x
let deserialize (json : string) = serdes.Deserialize json

module Contract2 =

Expand All @@ -23,12 +34,13 @@ module Contract2 =
type Item = { value : string option; other : TypeThatRequiresMyCustomConverter }
/// Settings to be used within this contract
// note OptionConverter is also included by default
let settings = FsCodec.NewtonsoftJson.Settings.Create(converters = [| MyCustomConverter() |])
let serialize (x : Item) = FsCodec.NewtonsoftJson.Serdes.Serialize(x,settings)
let deserialize (json : string) : Item = FsCodec.NewtonsoftJson.Serdes.Deserialize(json,settings)
let private serdes = FsCodec.NewtonsoftJson.Settings.Create(converters = [| MyCustomConverter() |]) |> FsCodec.NewtonsoftJson.Serdes
let serialize (x : Item) = serdes.Serialize x
let deserialize (json : string) : Item = serdes.Deserialize json

let inline ser x = Serdes.Serialize(x)
let inline des<'t> x = Serdes.Deserialize<'t>(x)
let private serdes = FsCodec.NewtonsoftJson.Settings.Create() |> FsCodec.NewtonsoftJson.Serdes
let inline ser x = serdes.Serialize(x)
let inline des<'t> x = serdes.Deserialize<'t>(x)

(* Global vs local Converters
Expand All @@ -49,8 +61,8 @@ ser { a = "testing"; b = Guid.Empty }
ser Guid.Empty
// "00000000-0000-0000-0000-000000000000"

let settings = Settings.Create(converters = [| GuidConverter() |])
Serdes.Serialize(Guid.Empty, settings)
let serdesWithGuidConverter = Settings.Create(converters = [| GuidConverter() |]) |> Serdes
serdesWithGuidConverter.Serialize(Guid.Empty)
// 00000000000000000000000000000000

(* TypeSafeEnumConverter basic usage *)
Expand Down
25 changes: 13 additions & 12 deletions tests/FsCodec.NewtonsoftJson.Tests/SomeNullHandlingTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,21 @@ open FsCodec.NewtonsoftJson
open Swensen.Unquote
open Xunit

let def = Settings.CreateDefault()
let ootb = Settings.CreateDefault() |> Serdes
let serdes = Settings.Create() |> Serdes

let [<Fact>] ``Settings.CreateDefault roundtrips null string option, but rendering is ugly`` () =
let value : string option = Some null
let ser = Serdes.Serialize(value, def)
let ser = ootb.Serialize value
test <@ ser = "{\"Case\":\"Some\",\"Fields\":[null]}" @>
test <@ value = Serdes.Deserialize(ser, def) @>
test <@ value = ootb.Deserialize ser @>

let [<Fact>] ``Settings.Create does not roundtrip Some null`` () =
let value : string option = Some null
let ser = Serdes.Serialize value
let ser = serdes.Serialize value
"null" =! ser
// But it doesn't roundtrip
value <>! Serdes.Deserialize ser
value <>! serdes.Deserialize ser

let hasSomeNull value = TypeShape.Generic.exists(fun (x : string option) -> x = Some null) value
let replaceSomeNullsWithNone value = TypeShape.Generic.map (function Some (null : string) -> None | x -> x) value
Expand All @@ -31,10 +32,10 @@ let [<Fact>] ``Workaround is to detect and/or substitute such non-roundtrippable
let value : string option = replaceSomeNullsWithNone value
None =! value
test <@ (not << hasSomeNull) value @>
let ser = Serdes.Serialize value
let ser = serdes.Serialize value
ser =! "null"
// ... and validate that the [substituted] value did roundtrip
test <@ value = Serdes.Deserialize ser @>
test <@ value = serdes.Deserialize ser @>

type RecordWithStringOptions = { x : int; y : Nested }
and Nested = { z : string option }
Expand All @@ -44,12 +45,12 @@ let [<Fact>] ``Can detect and/or substitute null string option when using Settin
test <@ hasSomeNull value @>
let value = replaceSomeNullsWithNone value
test <@ (not << hasSomeNull) value @>
let ser = Serdes.Serialize value
let ser = serdes.Serialize value
ser =! """{"x":9,"y":{"z":null}}"""
test <@ value = Serdes.Deserialize ser @>
test <@ value = serdes.Deserialize ser @>

// As one might expect, the ignoreNulls setting is also honored
let ignoreNullsSettings = Settings.Create(ignoreNulls=true)
let ser = Serdes.Serialize(value,ignoreNullsSettings)
let ignoreNullsSerdes = Settings.Create(ignoreNulls=true) |> Serdes
let ser = ignoreNullsSerdes.Serialize value
ser =! """{"x":9,"y":{}}"""
test <@ value = Serdes.Deserialize ser @>
test <@ value = serdes.Deserialize ser @>
Loading

0 comments on commit bed22c3

Please sign in to comment.