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

SystemTextJson: Support automatic TypeSafeEnum/Union converter selection #69

Merged
merged 14 commits into from
Jan 5, 2022
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ The `Unreleased` section name is replaced by the expected version of next releas
## [Unreleased]

### Added

- `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)
- `SystemTextJson.Options(autoUnion = true)`: Automated wireup of `UnionOrTypeSafeEnumConverterFactory` [#69](https://github.com/jet/FsCodec/pull/69)

### 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>
47 changes: 29 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ The respective concrete Codec packages include relevant `Converter`/`JsonConvert

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


### `FsCodec.SystemTextJson`-specific low level converters

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

## `FsCodec.NewtonsoftJson.Settings`

[`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:
Expand All @@ -110,14 +114,16 @@ The respective concrete Codec packages include relevant `Converter`/`JsonConvert
[`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:
- `CreateDefault`: equivalent to generating a `new JsonSerializerSettings()` without any overrides of any kind
- `Create`: as `CreateDefault` with the following difference:
- Inhibits the HTML-safe escaping that `System.Text.Json` provides as a default by overriding `Encoder` with `System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping`
- 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`
- `(camelCase = true)`: opts into camel case conversion for `PascalCased` properties and `Dictionary` keys
- `(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

## `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,35 +143,38 @@ 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:
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.

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:

let serdes = FsCodec.SystemTextJson.Options.Create(autoUnion = true) |> 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._

# Examples: `FsCodec.(Newtonsoft|SystemText)Json`

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

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 +185,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 +201,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)
1 change: 1 addition & 0 deletions src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<Compile Include="Pickler.fs" />
<Compile Include="UnionConverter.fs" />
<Compile Include="TypeSafeEnumConverter.fs" />
<Compile Include="UnionOrTypeSafeEnumConverterFactory.fs" />
<Compile Include="Options.fs" />
<Compile Include="Codec.fs" />
<Compile Include="Serdes.fs" />
Expand Down
11 changes: 9 additions & 2 deletions src/FsCodec.SystemTextJson/Options.fs
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,17 @@ 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,
converters =
( if autoUnion = Some true then
let converter : JsonConverter array = [| UnionOrTypeSafeEnumConverterFactory() |]
if converters = null then converter else Array.append converters converter
else converters),
?ignoreNulls = ignoreNulls,
?indent = indent,
?camelCase = camelCase,
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)
15 changes: 5 additions & 10 deletions src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,12 @@ open System.Text.Json
/// Utilities for working with DUs where none of the cases have a value
module TypeSafeEnum =

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

let tryParseT (t : Type) predicate =
if not (Union.isUnion t) then invalidArg "t" "Type must be a FSharpUnion." else

let u = Union.getUnion t
let u = Union.getInfo t
u.cases
|> Array.tryFindIndex (fun c -> predicate c.Name)
|> Option.map (fun tag -> u.caseConstructor.[tag] [||])
Expand All @@ -31,9 +28,7 @@ module TypeSafeEnum =
let parse<'T> (str : string) = parseT typeof<'T> str :?> 'T

let toString<'t> (x : 't) =
if not (Union.isUnion (typeof<'t>)) then invalidArg "'t" "Type must be a FSharpUnion." else

let u = Union.getUnion (typeof<'t>)
let u = Union.getInfo typeof<'t>
let tag = u.tagReader (box x)
// TOCONSIDER memoize and/or push into `Union` https://github.com/jet/FsCodec/pull/41#discussion_r394473137
u.cases.[tag].Name
Expand Down
Loading