From 9b524de184be51b5450506f85df02bfa15b01cd5 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Sun, 10 May 2020 01:35:33 +0100 Subject: [PATCH] Add FsCodec.SystemTextJson re #14 (#38) --- FsCodec.sln | 56 ++++ README.md | 118 +++++--- build.proj | 4 + .../BoxCodec.fs => FsCodec.Box/Codec.fs} | 0 src/FsCodec.Box/FsCodec.Box.fsproj | 28 ++ src/FsCodec.NewtonsoftJson/Codec.fs | 10 +- .../FsCodec.NewtonsoftJson.fsproj | 1 - src/FsCodec.SystemTextJson/Codec.fs | 134 +++++++++ .../FsCodec.SystemTextJson.fsproj | 37 +++ src/FsCodec.SystemTextJson/Interop.fs | 58 ++++ .../JsonOptionConverter.fs | 36 +++ .../JsonSerializerElementExtensions.fs | 26 ++ src/FsCodec.SystemTextJson/Options.fs | 59 ++++ src/FsCodec.SystemTextJson/Pickler.fs | 60 ++++ src/FsCodec.SystemTextJson/Serdes.fs | 36 +++ .../TypeSafeEnumConverter.fs | 54 ++++ src/FsCodec.SystemTextJson/UnionConverter.fs | 27 ++ .../FsCodec.NewtonsoftJson.Tests.fsproj | 8 +- .../VerbatimUtf8ConverterTests.fs | 35 +-- .../CodecTests.fs | 69 +++++ .../FsCodec.SystemTextJson.Tests/Examples.fsx | 262 ++++++++++++++++++ .../FsCodec.SystemTextJson.Tests.fsproj | 35 +++ .../InteropTests.fs | 48 ++++ .../PicklerTests.fs | 61 ++++ .../SerdesTests.fs | 117 ++++++++ .../TypeSafeEnumConverterTests.fs | 46 +++ .../UmxInteropTests.fs | 36 +++ tests/FsCodec.Tests/FsCodec.Tests.fsproj | 24 ++ .../StreamNameTests.fs | 0 29 files changed, 1428 insertions(+), 57 deletions(-) rename src/{FsCodec.NewtonsoftJson/BoxCodec.fs => FsCodec.Box/Codec.fs} (100%) create mode 100644 src/FsCodec.Box/FsCodec.Box.fsproj create mode 100755 src/FsCodec.SystemTextJson/Codec.fs create mode 100644 src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj create mode 100644 src/FsCodec.SystemTextJson/Interop.fs create mode 100644 src/FsCodec.SystemTextJson/JsonOptionConverter.fs create mode 100644 src/FsCodec.SystemTextJson/JsonSerializerElementExtensions.fs create mode 100755 src/FsCodec.SystemTextJson/Options.fs create mode 100755 src/FsCodec.SystemTextJson/Pickler.fs create mode 100755 src/FsCodec.SystemTextJson/Serdes.fs create mode 100755 src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs create mode 100755 src/FsCodec.SystemTextJson/UnionConverter.fs create mode 100644 tests/FsCodec.SystemTextJson.Tests/CodecTests.fs create mode 100755 tests/FsCodec.SystemTextJson.Tests/Examples.fsx create mode 100644 tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj create mode 100644 tests/FsCodec.SystemTextJson.Tests/InteropTests.fs create mode 100644 tests/FsCodec.SystemTextJson.Tests/PicklerTests.fs create mode 100644 tests/FsCodec.SystemTextJson.Tests/SerdesTests.fs create mode 100644 tests/FsCodec.SystemTextJson.Tests/TypeSafeEnumConverterTests.fs create mode 100644 tests/FsCodec.SystemTextJson.Tests/UmxInteropTests.fs create mode 100644 tests/FsCodec.Tests/FsCodec.Tests.fsproj rename tests/{FsCodec.NewtonsoftJson.Tests => FsCodec.Tests}/StreamNameTests.fs (100%) diff --git a/FsCodec.sln b/FsCodec.sln index 4c4c586..b5c838e 100644 --- a/FsCodec.sln +++ b/FsCodec.sln @@ -22,6 +22,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".project", ".project", "{1D EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec", "src\FsCodec\FsCodec.fsproj", "{9D2A9566-9C80-4AF3-A487-76A9FE8CBE64}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec.Tests", "tests\FsCodec.Tests\FsCodec.Tests.fsproj", "{0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec.SystemTextJson", "src\FsCodec.SystemTextJson\FsCodec.SystemTextJson.fsproj", "{1A27C90F-85EE-4AE6-A27B-183D0D50F62E}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec.SystemTextJson.Tests", "tests\FsCodec.SystemTextJson.Tests\FsCodec.SystemTextJson.Tests.fsproj", "{5C57C6D6-59AB-426F-9999-FDB90864545E}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FsCodec.Box", "src\FsCodec.Box\FsCodec.Box.fsproj", "{93AF284E-BD31-456E-96AC-162C746F9479}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -68,6 +76,54 @@ Global {9D2A9566-9C80-4AF3-A487-76A9FE8CBE64}.Release|x64.Build.0 = Release|Any CPU {9D2A9566-9C80-4AF3-A487-76A9FE8CBE64}.Release|x86.ActiveCfg = Release|Any CPU {9D2A9566-9C80-4AF3-A487-76A9FE8CBE64}.Release|x86.Build.0 = Release|Any CPU + {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Debug|x64.ActiveCfg = Debug|Any CPU + {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Debug|x64.Build.0 = Debug|Any CPU + {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Debug|x86.ActiveCfg = Debug|Any CPU + {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Debug|x86.Build.0 = Debug|Any CPU + {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Release|Any CPU.Build.0 = Release|Any CPU + {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Release|x64.ActiveCfg = Release|Any CPU + {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Release|x64.Build.0 = Release|Any CPU + {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Release|x86.ActiveCfg = Release|Any CPU + {0A1529E7-8DEF-4B2B-9737-3DB7BD3F1954}.Release|x86.Build.0 = Release|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|x64.ActiveCfg = Debug|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|x64.Build.0 = Debug|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|x86.ActiveCfg = Debug|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Debug|x86.Build.0 = Debug|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|Any CPU.Build.0 = Release|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|x64.ActiveCfg = Release|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|x64.Build.0 = Release|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|x86.ActiveCfg = Release|Any CPU + {1A27C90F-85EE-4AE6-A27B-183D0D50F62E}.Release|x86.Build.0 = Release|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|x64.ActiveCfg = Debug|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|x64.Build.0 = Debug|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|x86.ActiveCfg = Debug|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Debug|x86.Build.0 = Debug|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|Any CPU.Build.0 = Release|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|x64.ActiveCfg = Release|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|x64.Build.0 = Release|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|x86.ActiveCfg = Release|Any CPU + {5C57C6D6-59AB-426F-9999-FDB90864545E}.Release|x86.Build.0 = Release|Any CPU + {93AF284E-BD31-456E-96AC-162C746F9479}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {93AF284E-BD31-456E-96AC-162C746F9479}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93AF284E-BD31-456E-96AC-162C746F9479}.Debug|x64.ActiveCfg = Debug|Any CPU + {93AF284E-BD31-456E-96AC-162C746F9479}.Debug|x64.Build.0 = Debug|Any CPU + {93AF284E-BD31-456E-96AC-162C746F9479}.Debug|x86.ActiveCfg = Debug|Any CPU + {93AF284E-BD31-456E-96AC-162C746F9479}.Debug|x86.Build.0 = Debug|Any CPU + {93AF284E-BD31-456E-96AC-162C746F9479}.Release|Any CPU.ActiveCfg = Release|Any CPU + {93AF284E-BD31-456E-96AC-162C746F9479}.Release|Any CPU.Build.0 = Release|Any CPU + {93AF284E-BD31-456E-96AC-162C746F9479}.Release|x64.ActiveCfg = Release|Any CPU + {93AF284E-BD31-456E-96AC-162C746F9479}.Release|x64.Build.0 = Release|Any CPU + {93AF284E-BD31-456E-96AC-162C746F9479}.Release|x86.ActiveCfg = Release|Any CPU + {93AF284E-BD31-456E-96AC-162C746F9479}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 2e252ad..8327553 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,18 @@ The components within this repository are delivered as multi-targeted Nuget pack - [`FsCodec.IEventCodec`](https://github.com/jet/FsCodec/blob/master/src/FsCodec/FsCodec.fs#L19): defines a base interface for serializers. - [`FsCodec.Codec`](https://github.com/jet/FsCodec/blob/master/src/FsCodec/Codec.fs#L5): enables plugging in a serializer and/or Union Encoder of your choice (typically this is used to supply a pair `encode` and `tryDecode` functions) - [`FsCodec.StreamName`](https://github.com/jet/FsCodec/blob/master/src/FsCodec/StreamName.fs): strongly-typed wrapper for a Stream Name, together with factory functions and active patterns for parsing same +- [![Box Codec NuGet](https://img.shields.io/nuget/v/FsCodec.Box.svg)](https://www.nuget.org/packages/FsCodec.Box/) `FsCodec.Box`: See [`FsCodec.Box.Codec`](#boxcodec); `IEventCodec` implementation that provides a null encode/decode step in order to enable decoupling of serialization/deserialization concerns from the encoding aspect, typically used together with [`Equinox.MemoryStore`](https://www.fuget.org/packages/Equinox.MemoryStore) + - [depends](https://www.fuget.org/packages/FsCodec.Box) on `FsCodec`, `TypeShape >= 8` - [![Newtonsoft.Json Codec NuGet](https://img.shields.io/nuget/v/FsCodec.NewtonsoftJson.svg)](https://www.nuget.org/packages/FsCodec.NewtonsoftJson/) `FsCodec.NewtonsoftJson`: As described in [a scheme for the serializing Events modelled as an F# Discriminated Union](https://eiriktsarpalis.wordpress.com/2018/10/30/a-contract-pattern-for-schemaless-datastores/), enabled tagging of F# Discriminated Union cases in a versionable manner with low-dependencies using [TypeShape](https://github.com/eiriktsarpalis/TypeShape)'s [`UnionContractEncoder`](https://eiriktsarpalis.wordpress.com/2018/10/30/a-contract-pattern-for-schemaless-datastores) - Uses the ubiquitous [`Newtonsoft.Json`](https://github.com/JamesNK/Newtonsoft.Json) library to serialize the event bodies. - Provides relevant Converters for common non-primitive types prevalent in F# - [depends](https://www.fuget.org/packages/FsCodec.NewtonsoftJson) on `FsCodec`, `Newtonsoft.Json >= 11.0.2`, `TypeShape >= 8`, `Microsoft.IO.RecyclableMemoryStream >= 1.2.2`, `System.Buffers >= 4.5` -- [_(planned)_ `FsCodec.SystemTextJson`](https://github.com/jet/FsCodec/issues/14): drop in replacement that allows one to retarget from `Newtonsoft.Json` to imminently ubiquitous .NET `System.Text.Json` serializer solely by changing the referenced namespace. +- [![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 >= 5.0.0-preview.3`, `TypeShape >= 8` + + Deltas in behavior/functionality vs `FsCodec.NewtonsoftJson`: + + 1. [`UnionConverter` is WIP](https://github.com/jet/FsCodec/pull/43); model-binding related functionality that `System.Text.Json` does not provide equivalents will not be carried forward (e.g., `MissingMemberHandling`) # Features: `FsCodec` @@ -31,43 +38,90 @@ The purpose of the `FsCodec` package is to provide a minimal interface on which - [`FsCodec.Core.EventData.Create`](https://github.com/jet/FsCodec/blob/master/src/FsCodec/FsCodec.fs#L44) is a low level helper to create an `IEventData` directly for purposes such as tests etc. - [`FsCodec.Core.TimelineEvent.Create`](https://github.com/jet/FsCodec/blob/master/src/FsCodec/FsCodec.fs#L58) is a low level helper to create an `ITimelineEvent` directly for purposes such as tests etc. -# Examples: `FsCodec.NewtonsoftJson` +# Features: `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...) +## Common API -# Features: `FsCodec.NewtonsoftJson` +The concrete implementations implement common type/member/function signatures and behavior that offer consistent behavior using either `Newtonsoft.Json` or `System.Text.Json`, emphasizing the following qualities: -[`FsCodec.NewtonsoftJson.Codec`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/Codec.fs) provides an implementation of `IEventCodec` as described in [a scheme for the serializing Events modelled as an F# Discriminated Union](https://eiriktsarpalis.wordpress.com/2018/10/30/a-contract-pattern-for-schemaless-datastores/). This yields a clean yet versionable way of managing the roundtripping events based on a contract inferred from an F# Discriminated Union Type using `Newtonsoft.Json >= 11.0.2` to serialize the bodies. +- lean toward having straightforward encodings: + - tuples don't magically become arrays + - union bodies don't become arrays of mixed types (they become JSON Objects with named fields) +- don't surprise .NET developers used to Json.NET or System.Text.Json +- having an opinionated core set of behaviors, but don't conflict with the standard extensibility mechanisms afforded by the underlying serializer (one should be able to search up and apply answers from StackOverflow to questions regarding corner cases) +- maintain a minimal but well formed set of built in converters which are implemented per supported serializer - e.g., choices like not supporting F# `list` types -## `Newtonsoft.Json.Converter`s +## `Codec` -`FsCodec.NewtonsoftJson` includes relevant `Converters` in order to facilitate interoperable and versionable renderings: - - [`OptionConverter`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/OptionConverter.fs#L7) represents F#'s `Option<'t>` as a value or `null` - - [`TypeSafeEnumConverter`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/TypeSafeEnumConverter.fs#L33) represents discriminated union (whose cases are all nullary), as a `string` in a trustworthy manner (`Newtonsoft.Json.Converters.StringEnumConverter` permits values outside the declared values) :pray: [@amjjd](https://github.com/amjjd) - - [`UnionConverter`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/UnionConverter.fs#L71) represents F# discriminated unions as a single JSON `object` with both the tag value and the body content as named fields directly within(`Newtonsoft.Json.Converters.DiscriminatedUnionConverter` encodes the fields as an array without names, which has some pros, but many obvious cons) :pray: [@amjdd](https://github.com/amjjd) - - [`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) +[`FsCodec.NewtonsoftJson/SystemTextJson.Codec`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/Codec.fs) provides an implementation of `IEventCodec` as described in [a scheme for the serializing Events modelled as an F# Discriminated Union](https://eiriktsarpalis.wordpress.com/2018/10/30/a-contract-pattern-for-schemaless-datastores/). This yields a clean yet versionable way of managing the roundtripping events based on a contract inferred from an F# Discriminated Union Type using `Newtonsoft.Json >= 11.0.2` / `System.Text.Json` to serialize the bodies. + +## Converters: `Newtonsoft.Json.Converter`s / `System.Text.Json.Serialization.JsonConverter`s + +### Explicit vs Implicit + +While it's alluded to in the [recommendations](#recommendations), it's worth calling out that the converters in FsCodec (aside from obvious exceptions like the Option and Record ones) are intended to be used by tagging the type with a `JsonConverterAttribute` rather than by inclusion in the global converters list of the underlying serializer. + +The key effect of this is that any non-trivial mapping will manifest as the application of the relevant attribute on the `type` or property in question. This also aligns well with the notion of cordoning off a `module Events` as described in [Equinox's `module Aggregate` documentation]( +https://github.com/jet/equinox/blob/master/DOCUMENTATION.md#aggregate-module): `type`s that participate in an Event union are defined _and namespaced_ together (including any snapshot serialization contracts). + +### This set might be all you need ... + +While this may not seem like a sufficiently large set of converters for a large app, it should be mentioned that the serializer-neutral escape hatch represented by [`JsonIsomorphism`](#jsonisimorphism) has resulted in this set alone proving sufficient for two major subsystems of a large e-commerce software suite. See [recommendations](#recommendations) for further expansion on this (TL;DR it does mean ruling out using some type constructs directly in event and/or binding contracts and using [Anti Corruption Layer](https://docs.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer) and/or [event versioning](https://leanpub.com/esversioning) techniques. + +### ... but don't forget `FSharp.SystemTextJson` -## Custom converter base classes +`System.Text.Json` v `4.x` did not even support F# records that are not marked `[]` out-of-the-box (it was similarly spartan wrt C# types, requiring a default constructor on `class`es). The `>= 5.0` that `FsCodec.System.Text.Json` requires does support records, but it doesnt support Discriminated Unions, `option`s, `list`s, `Set` or `Map` out of the box. It's worth calling out explicitly that there are no plans to extend the representations `FsCodec.SystemTextJson` can handle in any significant way over time ([the advice for `FsCodec.NewtonsoftJson` has always been to avoid stuff outside of records, `option`s and `array`s](#recommendations)) - if you have specific exotic corner cases and determine you need something more specifically tailored, the Converters abstraction affords you ability to mix and match from the [`FSharp.SystemTextJson`](https://github.com/Tarmil/FSharp.SystemTextJson) library - it provides a much broader and complete (and well tested) set of converters with a broader remit than what FsCodec is trying to maintain as its sweet spot. + +### Core converters -- [`JsonIsomorphism`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/Pickler.fs#L49) - allows one to cleanly map a type's internal representation to something that Json.net can already cleanly handle :pray: [@EirikTsarpalis](https://github.com/eiriktsarpalis) +The respective concrete Codec packages include relevant `Converter`/`JsonConverter` in order to facilitate interoperable and versionable renderings: + - `JsonOptionConverter` / [`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`/`Options.Create` profile. `System.Text.Json` reimplementation :pray: [@ylibrach](https://github.com/ylibrach) + - [`TypeSafeEnumConverter`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/TypeSafeEnumConverter.fs#L33) represents discriminated union (whose cases are all nullary), as a `string` in a trustworthy manner (`Newtonsoft.Json.Converters.StringEnumConverter` permits values outside the declared values) :pray: [@amjjd](https://github.com/amjjd) + - [`UnionConverter`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/UnionConverter.fs#L71) represents F# discriminated unions as a single JSON `object` with both the tag value and the body content as named fields directly within :pray: [@amjdd](https://github.com/amjjd); `System.Text.Json` reimplementation :pray: [@NickDarvey](https://github.com/NickDarvey) + + NOTE: The encoding differs from that provided by `NewtonsoftJson`'s default converter: `Newtonsoft.Json.Converters.DiscriminatedUnionConverter`, which encodes the fields as an array without names, which has some pros, but many obvious cons + + NOTE `System.Text.Json` does not support F# unions out of the box. It's not intended to extend the representations `FsCodec.SystemTextJson` can handle in any significant way over time - if you have specific requirements, the powerful and complete [`FSharp.SystemTextJson`](https://github.com/Tarmil/FSharp.SystemTextJson) library is likely your best option in this space. + +### Custom converter base classes + +- [`JsonIsomorphism`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/Pickler.fs#L49) - allows one to cleanly map a type's internal representation to something that the underlying serializer and converters can already cleanly handle :pray: [@EirikTsarpalis](https://github.com/eiriktsarpalis) - [`JsonPickler`](https://github.com/jet/FsCodec/blob/master/src/FsCodec.NewtonsoftJson/Pickler.fs#L15) - removes boilerplate from simple converters, used in implementation of `JsonIsomorphism` :pray: [@EirikTsarpalis](https://github.com/eiriktsarpalis) -## `Settings` +### `FsCodec.NewtonsoftJson`-specific low level converters + + - [`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.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: - `CreateDefault`: as per `Newtonsoft.Json` defaults with the following override: - `DateTimeZoneHandling = DateTimeZoneHandling.Utc` (default is `RoundtripKind`) - no custom `IContractResolver` (one is expected to use `camelCase` field names within records, for which this does not matter) - `Create`: as `CreateDefault` with the following difference: - - adds an `OptionConverter`; included in default `Settings` (see _Converters_, above and _Setttings_ below) + - adds an `OptionConverter` (see _Converters_, below) + +## `FsCodec.SystemTextJson.Options` + +[`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: + - adds a `JsonOptionConverter`; included in default `Settings` (see _Converters_, below) + - Inhibits the HTML-safe escaping that `System.Text.Json` provides as a default by overriding `Encoder` with `System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping` ## `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.Create` (above). Methods: -- `Serialize`: serializes an object per its type using the settings defined in `Settings.Create` -- `Deserialize`: deserializes an object per its type using the settings defined in `Settings.Create` +[`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: +- `Serialize`: serializes an object per its type using the settings defined in `Settings/Options.Create` +- `Deserialize`: deserializes an object per its type using the settings defined in `Settings/Options.Create` + +# 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). - + ### Examples of using `Settings` and `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: @@ -104,11 +158,11 @@ module Contract = ### Recommended round-trippable constructs -`Newtonsoft.Json`, thanks to its broad usage thoughout .NET systems has well known (with some idiosyncratic quirks) behaviors for most common types one might use for C# DTOs. +`Newtonsoft.Json`, thanks to its broad usage throughout .NET systems has well known (with some idiosyncratic quirks) behaviors for most common types one might use for C# DTOs. Normal primitive F#/.NET such as `bool`, `byte`, `int16`, `int`, `int64`, `float32` (`Single`), `float` (`Double`), `decimal` work as expected. -The default settings for FsCodec applies Json.net's default behavior, whis is to render fields that have a `null` or `null`-equivalent value with the value `null`. This behavior can be overridden via `Settings(ignoreNulls = true)`, which will cause such JSON fields to be omitted. +The default settings for FsCodec applies Json.NET's default behavior, whis is to render fields that have a `null` or `null`-equivalent value with the value `null`. This behavior can be overridden via `Settings(ignoreNulls = true)`, which will cause such JSON fields to be omitted. The recommendations here apply particularly to Event Contracts - the data in your store will inevitably outlast your code, so being conservative in the complexity of ones's encoding scheme is paramount. Explicit is better than Implicit. @@ -116,12 +170,12 @@ The recommendations here apply particularly to Event Contracts - the data in you | :--- | :--- | :--- | :--- | :--- | | `'t[]` | As per C# | Don't forget to handle `null` | `[ 1; 2; 3]` | `[1,2,3]` | | `DateTimeOffset` | Roundtrips cleanly | The default `Settings.Create` requests `RoundtripKind` | `DateTimeOffset.Now` | `"2019-09-04T20:30:37.272403+01:00"` | -| `Nullable<'t>` | As per C#; `Nullable()` -> `null`, `Nullable x` -> `x` | OOTB Json.net roundtrips cleanly. Works with `Settings.CreateDefault()`. Worth considering if your contract does not involve many `option` types | `Nullable 14` | `14` | -| `'t option` | `None` -> `null`, `Some x` -> `x` _with the converter `Settings.Create()` adds_ | OOTB Json.net does not roundtrip `option` types cleanly; `Settings.Create` and `NewtonsoftJson.Codec.Create` wire in an `OptionConverter` by default | `Some 14` | `14` | +| `Nullable<'t>` | As per C#; `Nullable()` -> `null`, `Nullable x` -> `x` | OOTB Json.NET and STJ roundtrip cleanly. Works with `Settings.CreateDefault()`. Worth considering if your contract does not involve many `option` types | `Nullable 14` | `14` | +| `'t option` | `Some null`,`None` -> `null`, `Some x` -> `x` _with the converter `Settings.Create()` adds_ | OOTB Json.NET and STJ do not roundtrip `option` types cleanly; `Settings/Options/Codec.Create` wire in an `OptionConverter` by default
NOTE `Some null` will produce `null`, but deserialize as `None` - i.e., it's not round-trippable | `Some 14` | `14` | | `string` | As per C#; need to handle `null` | One can use a `string option` to map `null` and `Some null` to `None` | `"Abc"` | `"Abc"` | -| types with unit of measure | Works well (doesnt encode the unit) | Unit of measure tags are only known to the compiler; Json.net does not process the tags and treats it as the underlying primitive type | `54` | `54` | -| [`FSharp.UMX`](https://github.com/fsprojects/FSharp.UMX) tagged `string`, `DateTimeOffset` | Works well | [`FSharp.UMX`](https://github.com/fsprojects/FSharp.UMX) enables one to type-tag `string` and `DateTimeOffset` values using the units of measure compiler feature, which Json.net will render as if they were unadorned | `SkuId.parse "54-321"` | `"000-054-321"` | -| records | Just work | FYI records are not supported OOTB yet in `System.Text.Json` | `{\| a = 1; b = Some "x" \|}` | `"{"a":1,"b":"x"}"` | +| types with unit of measure | Works well (doesnt encode the unit) | Unit of measure tags are only known to the compiler; Json.NET does not process the tags and treats it as the underlying primitive type | `54` | `54` | +| [`FSharp.UMX`](https://github.com/fsprojects/FSharp.UMX) tagged `string`, `DateTimeOffset` | Works well | [`FSharp.UMX`](https://github.com/fsprojects/FSharp.UMX) enables one to type-tag `string` and `DateTimeOffset` values using the units of measure compiler feature, which Json.NET will render as if they were unadorned | `SkuId.parse "54-321"` | `"000-054-321"` | +| records | Just work | For `System.Text.Json` v `4.x`, usage of `[]` or a custom `JsonRecordConverter` was once required | `{\| a = 1; b = Some "x" \|}` | `"{"a":1,"b":"x"}"` | | Nullary unions (Enum-like DU's without bodies) | Tag `type` with `TypeSafeEnumConverter` | Works well - guarantees a valid mapping, as opposed to using a `System.Enum` and `StringEnumConverter`, which can map invalid values and/or silently map to `0` etc | `State.NotFound` | `"NotFound"` | | Discriminated Unions (where one or more cases has a body) | Tag `type` with `UnionConverter` | This format can be readily consumed in Java, JavaScript and Swift. Nonetheless, exhaust all other avenues before considering encoding a union in JSON. The `"case"` label id can be overridden. | `Decision.Accepted { result = "54" }` | `{"case": "Accepted","result":"54"}` | @@ -132,10 +186,10 @@ The mechanisms in the previous section have proven themselves sufficient for div | Type kind | TL;DR | Example input | Example output | Notes | | :--- | :--- | :--- | :--- | :--- | | `'t list` | __Don't use__; use `'t[]` | `[ 1; 2; 3]` | `[1,2,3]` | While the happy path works, `null` or missing field maps to a `null` object rather than `[]` [which is completely wrong from an F# perspective] | -| `DateTime` | __Don't use__; use `DateTimeOffset` | | | Roundtripping can be messy, wrong or lossy; `DateTimeOffset` covers same use cases | +| `DateTime` | __Don't use__; use `DateTimeOffset` | | | Round-tripping can be messy, wrong or lossy; `DateTimeOffset` covers same use cases | | `Guid` or [`FSharp.UMX`](https://github.com/fsprojects/FSharp.UMX) tagged `Guid` | __don't use__; wrap as a reference `type` and use a `JsonIsomorphism`, or represent as a tagged `string` | `Guid.NewGuid()` | `"ba7024c7-6795-413f-9f11-d3b7b1a1fe7a"` | If you wrap the value in a type, you can have that roundtrip with a specific format via a Converter implemented as a `JsonIsomorphism`. Alternately, represent in your contract as a [`FSharp.UMX`](https://github.com/fsprojects/FSharp.UMX) tagged-string. | -| maps/`Dictionary` etc. | avoid; prefer arrays | | | As per C#; not always the best option for many reasons, both on the producer and consumer side. Json.net has support for various maps with various idiosyncracies typically best covered by Stack Overflow, but often a list of records is clearer | -| tuples | __Don't use__; use records | `(1,2)` | `{"Item1":1,"Item2":2}` | While converters are out there, using tuples in contracts ofany kind is simply Not A Good Idea | +| maps/`Dictionary` etc. | avoid; prefer arrays | | | As per C#; not always the best option for many reasons, both on the producer and consumer side. Json.NET has support for various maps with various idiosyncracies typically best covered by Stack Overflow, but often a list of records is clearer
For `System.Text.Json`, use an `IDictionary<'K, 'V>` or `Dictionary<'K, 'V>` | +| tuples | __Don't use__; use records | `(1,2)` | `{"Item1":1,"Item2":2}` | While converters are out there, using tuples in contracts of any kind is simply Not A Good Idea | ## Custom converters using `JsonIsomorphism` @@ -566,7 +620,7 @@ which yields the following output: # Features: `FsCodec.Box.Codec` -`FsCodec.Box.Codec` is a drop-in-equivalent for `FsCodec.NewtonsoftJson.Codec` with equivalent `.Create` overloads that encode as `ITimelineEvent` (as opposed to `ITimelineEvent`. +`FsCodec.Box.Codec` is a drop-in-equivalent for `FsCodec.(Newtonsoft|SystemText)Json.Codec` with equivalent `.Create` overloads that encode as `ITimelineEvent` (as opposed to `ITimelineEvent` / `ITimelineEvent`). This is useful when storing events in a `MemoryStore` as it allows one to take the perf cost and ancillary yak shaving induced by round-tripping arbitrary event payloads to the concrete serialization format out of the picture when writing property based unit and integration tests. @@ -587,8 +641,8 @@ Examples, tests and docs are welcomed with open arms. General guidelines: - Less [converters] is more - [has a converter _really_ proved itself broadly applicable](https://en.wikipedia.org/wiki/Rule_of_three_(computer_programming)) ? -- this is not the final complete set of converters; Json.net is purposefully extensible and limited only by your imagination, for better or worse. However such specific conversions are best kept within the app. -- If `Newtonsoft.Json` can or should be made to do something, it should - this library is for extensions that absolutely positively can't go into Json.net itself. +- this is not the final complete set of converters; Json.NET and System.Text.Json are purposefully extensible and limited only by your imagination, for better or worse. However such specific conversions are best kept within the app. +- If the upstream library (`Newtonsoft.Json`, `System.Text.Json`) can or should be made to do something, it should. Also for `System.Text.Json`, if it's an F#-specific, the powerful and complete [`FSharp.SystemTextJson`](https://github.com/Tarmil/FSharp.SystemTextJson) library may be much more aligned. Please raise GitHub issues for any questions so others can benefit from the discussion. diff --git a/build.proj b/build.proj index fbb834b..797972d 100644 --- a/build.proj +++ b/build.proj @@ -13,11 +13,15 @@ + + + + diff --git a/src/FsCodec.NewtonsoftJson/BoxCodec.fs b/src/FsCodec.Box/Codec.fs similarity index 100% rename from src/FsCodec.NewtonsoftJson/BoxCodec.fs rename to src/FsCodec.Box/Codec.fs diff --git a/src/FsCodec.Box/FsCodec.Box.fsproj b/src/FsCodec.Box/FsCodec.Box.fsproj new file mode 100644 index 0000000..963705e --- /dev/null +++ b/src/FsCodec.Box/FsCodec.Box.fsproj @@ -0,0 +1,28 @@ + + + + netstandard2.0 + 5 + false + true + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/FsCodec.NewtonsoftJson/Codec.fs b/src/FsCodec.NewtonsoftJson/Codec.fs index 31cbf88..f979580 100755 --- a/src/FsCodec.NewtonsoftJson/Codec.fs +++ b/src/FsCodec.NewtonsoftJson/Codec.fs @@ -81,15 +81,19 @@ type Codec private () = let dataCodec = TypeShape.UnionContract.UnionContractEncoder.Create<'Contract, byte[]>( bytesEncoder, - requireRecordFields = true, // See JsonConverterTests - round-tripping UTF-8 correctly with Json.net is painful so for now we lock up the dragons + // For now, we hard wire in disabling of non-record bodies as: + // a) it's extra yaks to shave + // b) it's questionable whether allowing one to define event contracts that preclude adding extra fields is a useful idea in the first instance + // See VerbatimUtf8EncoderTests.fs and InteropTests.fs - there are edge cases when `d` fields have null / zero-length / missing values + requireRecordFields = true, allowNullaryCases = not (defaultArg rejectNullaryCases false)) { new FsCodec.IEventCodec<'Event, byte[], 'Context> with member __.Encode(context, event) = let (c, meta : 'Meta option, eventId, correlationId, causationId, timestamp : DateTimeOffset option) = down (context, event) let enc = dataCodec.Encode c - let metaUtf8 = meta |> Option.map bytesEncoder.Encode<'Meta> - FsCodec.Core.EventData.Create(enc.CaseName, enc.Payload, defaultArg metaUtf8 null, eventId, correlationId, causationId, ?timestamp = timestamp) + let metaUtf8 = match meta with Some x -> bytesEncoder.Encode<'Meta> x | None -> null + FsCodec.Core.EventData.Create(enc.CaseName, enc.Payload, metaUtf8, eventId, correlationId, causationId, ?timestamp = timestamp) member __.TryDecode encoded = match dataCodec.TryDecode { CaseName = encoded.EventType; Payload = encoded.Data } with diff --git a/src/FsCodec.NewtonsoftJson/FsCodec.NewtonsoftJson.fsproj b/src/FsCodec.NewtonsoftJson/FsCodec.NewtonsoftJson.fsproj index cf7b99e..8b9515c 100644 --- a/src/FsCodec.NewtonsoftJson/FsCodec.NewtonsoftJson.fsproj +++ b/src/FsCodec.NewtonsoftJson/FsCodec.NewtonsoftJson.fsproj @@ -15,7 +15,6 @@ - diff --git a/src/FsCodec.SystemTextJson/Codec.fs b/src/FsCodec.SystemTextJson/Codec.fs new file mode 100755 index 0000000..c3fe904 --- /dev/null +++ b/src/FsCodec.SystemTextJson/Codec.fs @@ -0,0 +1,134 @@ +namespace FsCodec.SystemTextJson.Core + +open System.Text.Json + +/// System.Text.Json implementation of TypeShape.UnionContractEncoder's IEncoder that encodes to a `JsonElement` +type JsonElementEncoder(options : JsonSerializerOptions) = + interface TypeShape.UnionContract.IEncoder with + member __.Empty = Unchecked.defaultof<_> + + member __.Encode(value : 'T) = + JsonSerializer.SerializeToElement(value, options) + + member __.Decode(json : JsonElement) = + JsonSerializer.DeserializeElement(json, options) + +namespace FsCodec.SystemTextJson + +open System +open System.Runtime.InteropServices +open System.Text.Json + +/// Provides Codecs that render to a JsonElement suitable for storage in Event Stores based using System.Text.Json and the conventions implied by using +/// TypeShape.UnionContract.UnionContractEncoder - if you need full control and/or have have your own codecs, see FsCodec.Codec.Create instead +/// See for example usage. +type Codec private () = + + static let defaultOptions = lazy Options.Create() + + /// Generate an IEventCodec using the supplied System.Text.Json options. + /// Uses up and down functions to facilitate upconversion/downconversion + /// and/or surfacing metadata to the Programming Model by including it in the emitted 'Event + /// The Event Type Names are inferred based on either explicit DataMember(Name= Attributes, or (if unspecified) the Discriminated Union Case Name + /// Contract must be tagged with interface TypeShape.UnionContract.IUnionContract to signify this scheme applies. + static member Create<'Event, 'Contract, 'Meta, 'Context when 'Contract :> TypeShape.UnionContract.IUnionContract> + ( /// Maps from the TypeShape UnionConverter 'Contract case the Event has been mapped to (with the raw event data as context) + /// to the 'Event representation (typically a Discriminated Union) that is to be presented to the programming model. + up : FsCodec.ITimelineEvent * 'Contract -> 'Event, + /// Maps a fresh Event resulting from a Decision in the Domain representation type down to the TypeShape UnionConverter 'Contract + /// The function is also expected to derive + /// a meta object that will be serialized with the same options (if it's not None) + /// and an Event Creation timestamp. + down : 'Context option * 'Event -> 'Contract * 'Meta option * Guid * string * string * DateTimeOffset option, + /// Configuration to be used by the underlying System.Text.Json Serializer when encoding/decoding. Defaults to same as Options.Create() + [] ?options, + /// Enables one to fail encoder generation if union contains nullary cases. Defaults to false, i.e. permitting them + [] ?rejectNullaryCases) + : FsCodec.IEventCodec<'Event, JsonElement, 'Context> = + + let options = match options with Some x -> x | None -> defaultOptions.Value + let elementEncoder : TypeShape.UnionContract.IEncoder<_> = Core.JsonElementEncoder(options) :> _ + let dataCodec = + TypeShape.UnionContract.UnionContractEncoder.Create<'Contract, JsonElement>( + elementEncoder, + // Round-tripping cases like null and/or empty strings etc involves edge cases that stores, + // FsCodec.NewtonsoftJson.Codec, Interop.fs and InteropTests.fs do not cover, so we disable this + requireRecordFields = true, + allowNullaryCases = not (defaultArg rejectNullaryCases false)) + + { new FsCodec.IEventCodec<'Event, JsonElement, 'Context> with + member __.Encode(context, event) = + let (c, meta : 'Meta option, eventId, correlationId, causationId, timestamp : DateTimeOffset option) = down (context, event) + let enc = dataCodec.Encode c + let meta' = match meta with Some x -> elementEncoder.Encode<'Meta> x | None -> Unchecked.defaultof<_> + FsCodec.Core.EventData.Create(enc.CaseName, enc.Payload, meta', eventId, correlationId, causationId, ?timestamp = timestamp) + + member __.TryDecode encoded = + match dataCodec.TryDecode { CaseName = encoded.EventType; Payload = encoded.Data } with + | None -> None + | Some contract -> up (encoded, contract) |> Some } + + /// Generate an IEventCodec using the supplied System.Text.Json options. + /// Uses up and down and mapCausation functions to facilitate upconversion/downconversion and correlation/causationId mapping + /// and/or surfacing metadata to the Programming Model by including it in the emitted 'Event + /// The Event Type Names are inferred based on either explicit DataMember(Name= Attributes, or (if unspecified) the Discriminated Union Case Name + /// Contract must be tagged with interface TypeShape.UnionContract.IUnionContract to signify this scheme applies. + static member Create<'Event, 'Contract, 'Meta, 'Context when 'Contract :> TypeShape.UnionContract.IUnionContract> + ( /// Maps from the TypeShape UnionConverter 'Contract case the Event has been mapped to (with the raw event data as context) + /// to the representation (typically a Discriminated Union) that is to be presented to the programming model. + up : FsCodec.ITimelineEvent * 'Contract -> 'Event, + /// Maps a fresh Event resulting from a Decision in the Domain representation type down to the TypeShape UnionConverter 'Contract + /// The function is also expected to derive + /// a meta object that will be serialized with the same options (if it's not None) + /// and an Event Creation timestamp. + down : 'Event -> 'Contract * 'Meta option * DateTimeOffset option, + /// Uses the 'Context passed to the Encode call and the 'Meta emitted by down to a) the final metadata b) the correlationId and c) the correlationId + mapCausation : 'Context option * 'Meta option -> 'Meta option * Guid * string * string, + /// Configuration to be used by the underlying System.Text.Json Serializer when encoding/decoding. Defaults to same as Options.Create() + [] ?options, + /// Enables one to fail encoder generation if union contains nullary cases. Defaults to false, i.e. permitting them + [] ?rejectNullaryCases) + : FsCodec.IEventCodec<'Event, JsonElement, 'Context> = + + let down (context, union) = + let c, m, t = down union + let m', eventId, correlationId, causationId = mapCausation (context, m) + c, m', eventId, correlationId, causationId, t + Codec.Create(up = up, down = down, ?options = options, ?rejectNullaryCases = rejectNullaryCases) + + /// Generate an IEventCodec using the supplied System.Text.Json options. + /// Uses up and down and mapCausation functions to facilitate upconversion/downconversion and correlation/causationId mapping + /// and/or surfacing metadata to the Programming Model by including it in the emitted 'Event + /// The Event Type Names are inferred based on either explicit DataMember(Name= Attributes, or (if unspecified) the Discriminated Union Case Name + /// Contract must be tagged with interface TypeShape.UnionContract.IUnionContract to signify this scheme applies. + static member Create<'Event, 'Contract, 'Meta when 'Contract :> TypeShape.UnionContract.IUnionContract> + ( /// Maps from the TypeShape UnionConverter 'Contract case the Event has been mapped to (with the raw event data as context) + /// to the representation (typically a Discriminated Union) that is to be presented to the programming model. + up : FsCodec.ITimelineEvent * 'Contract -> 'Event, + /// Maps a fresh 'Event resulting from a Decision in the Domain representation type down to the TypeShape UnionConverter 'Contract + /// The function is also expected to derive + /// a meta object that will be serialized with the same options (if it's not None) + /// and an Event Creation timestamp. + down : 'Event -> 'Contract * 'Meta option * DateTimeOffset option, + /// Configuration to be used by the underlying System.Text.Json Serializer when encoding/decoding. Defaults to same as Options.Create() + [] ?options, + /// Enables one to fail encoder generation if union contains nullary cases. Defaults to false, i.e. permitting them + [] ?rejectNullaryCases) + : FsCodec.IEventCodec<'Event, JsonElement, obj> = + + let mapCausation (_context : obj, m : 'Meta option) = m, Guid.NewGuid(), null, null + Codec.Create(up = up, down = down, mapCausation = mapCausation, ?options = options, ?rejectNullaryCases = rejectNullaryCases) + + /// Generate an IEventCodec using the supplied System.Text.Json options. + /// The Event Type Names are inferred based on either explicit DataMember(Name= Attributes, or (if unspecified) the Discriminated Union Case Name + /// 'Union must be tagged with interface TypeShape.UnionContract.IUnionContract to signify this scheme applies. + static member Create<'Union when 'Union :> TypeShape.UnionContract.IUnionContract> + ( // Configuration to be used by the underlying System.Text.Json Serializer when encoding/decoding. Defaults to same as Options.Create() + [] ?options, + /// Enables one to fail encoder generation if union contains nullary cases. Defaults to false, i.e. permitting them + [] ?rejectNullaryCases) + : FsCodec.IEventCodec<'Union, JsonElement, obj> = + + let up : FsCodec.ITimelineEvent<_> * 'Union -> 'Union = snd + let down (event : 'Union) = event, None, None + Codec.Create(up = up, down = down, ?options = options, ?rejectNullaryCases = rejectNullaryCases) diff --git a/src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj b/src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj new file mode 100644 index 0000000..fd22c06 --- /dev/null +++ b/src/FsCodec.SystemTextJson/FsCodec.SystemTextJson.fsproj @@ -0,0 +1,37 @@ + + + + netstandard2.1 + 5 + false + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/FsCodec.SystemTextJson/Interop.fs b/src/FsCodec.SystemTextJson/Interop.fs new file mode 100644 index 0000000..41ab502 --- /dev/null +++ b/src/FsCodec.SystemTextJson/Interop.fs @@ -0,0 +1,58 @@ +namespace FsCodec.SystemTextJson + +open System.Runtime.CompilerServices +open System.Text.Json + +[] +type InteropExtensions = + static member private Adapt<'From, 'To, 'Event, 'Context> + ( native : FsCodec.IEventCodec<'Event, 'From, 'Context>, + up : 'From -> 'To, + down : 'To -> 'From) : FsCodec.IEventCodec<'Event, 'To, 'Context> = + + { new FsCodec.IEventCodec<'Event, 'To, 'Context> with + member __.Encode(context, event) = + let encoded = native.Encode(context, event) + { new FsCodec.IEventData<_> with + member __.EventType = encoded.EventType + member __.Data = up encoded.Data + member __.Meta = up encoded.Meta + member __.EventId = encoded.EventId + member __.CorrelationId = encoded.CorrelationId + member __.CausationId = encoded.CausationId + member __.Timestamp = encoded.Timestamp } + + member __.TryDecode encoded = + let mapped = + { new FsCodec.ITimelineEvent<_> with + member __.Index = encoded.Index + member __.IsUnfold = encoded.IsUnfold + member __.Context = encoded.Context + member __.EventType = encoded.EventType + member __.Data = down encoded.Data + member __.Meta = down encoded.Meta + member __.EventId = encoded.EventId + member __.CorrelationId = encoded.CorrelationId + member __.CausationId = encoded.CausationId + member __.Timestamp = encoded.Timestamp } + native.TryDecode mapped } + + static member private MapFrom(x : byte[]) : JsonElement = + if x = null then JsonElement() + else JsonSerializer.Deserialize(System.ReadOnlySpan.op_Implicit x) + static member private MapTo(x: JsonElement) : byte[] = + if x.ValueKind = JsonValueKind.Undefined then null + else JsonSerializer.SerializeToUtf8Bytes(x, InteropExtensions.NoOverEscapingOptions) + // Avoid introduction of HTML escaping for things like quotes etc (as standard Options.Create() profile does) + static member private NoOverEscapingOptions = + System.Text.Json.JsonSerializerOptions(Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping) + + [] + static member ToByteArrayCodec<'Event, 'Context>(native : FsCodec.IEventCodec<'Event, JsonElement, 'Context>) + : FsCodec.IEventCodec<'Event, byte[], 'Context> = + InteropExtensions.Adapt(native, InteropExtensions.MapTo, InteropExtensions.MapFrom) + + [] + static member ToJsonElementCodec<'Event, 'Context>(native : FsCodec.IEventCodec<'Event, byte[], 'Context>) + : FsCodec.IEventCodec<'Event, JsonElement, 'Context> = + InteropExtensions.Adapt(native, InteropExtensions.MapFrom, InteropExtensions.MapTo) diff --git a/src/FsCodec.SystemTextJson/JsonOptionConverter.fs b/src/FsCodec.SystemTextJson/JsonOptionConverter.fs new file mode 100644 index 0000000..10bb73a --- /dev/null +++ b/src/FsCodec.SystemTextJson/JsonOptionConverter.fs @@ -0,0 +1,36 @@ +namespace FsCodec.SystemTextJson.Converters + +open System +open System.Linq.Expressions +open System.Text.Json +open System.Text.Json.Serialization + +type OptionConverterActivator = delegate of unit -> JsonConverter + +type JsonOptionConverter<'T> () = + inherit JsonConverter> () + + override __.Read(reader, _typ, options) = + match reader.TokenType with + | JsonTokenType.Null -> None + | _ -> JsonSerializer.Deserialize<'T>(&reader, options) |> Some + + override __.Write(writer, value, options) = + match value with + | None -> writer.WriteNullValue() + | Some v -> JsonSerializer.Serialize<'T>(writer, v, options) + +type JsonOptionConverter () = + inherit JsonConverterFactory() + + override __.CanConvert(t : Type) = + t.IsGenericType && t.GetGenericTypeDefinition() = typedefof> + + override __.CreateConverter (typ, _options) = + let valueType = typ.GetGenericArguments() |> Array.head + let constructor = typedefof>.MakeGenericType(valueType).GetConstructors() |> Array.head + let newExpression = Expression.New(constructor) + let lambda = Expression.Lambda(typeof, newExpression) + + let activator = lambda.Compile() :?> OptionConverterActivator + activator.Invoke() diff --git a/src/FsCodec.SystemTextJson/JsonSerializerElementExtensions.fs b/src/FsCodec.SystemTextJson/JsonSerializerElementExtensions.fs new file mode 100644 index 0000000..79b6446 --- /dev/null +++ b/src/FsCodec.SystemTextJson/JsonSerializerElementExtensions.fs @@ -0,0 +1,26 @@ +namespace FsCodec.SystemTextJson.Core + +open System +open System.Buffers +open System.Runtime.InteropServices +open System.Text.Json + +[] +module internal JsonSerializerExtensions = + type JsonSerializer with + static member SerializeToElement(value: 'T, [] ?options: JsonSerializerOptions) = + let span = ReadOnlySpan.op_Implicit(JsonSerializer.SerializeToUtf8Bytes(value, defaultArg options null)) + JsonSerializer.Deserialize(span) + + static member DeserializeElement<'T>(element: JsonElement, [] ?options: JsonSerializerOptions) = +#if NETSTANDARD2_0 + let json = element.GetRawText() + JsonSerializer.Deserialize<'T>(json, defaultArg options null) +#else + let bufferWriter = ArrayBufferWriter() + ( + use jsonWriter = new Utf8JsonWriter(bufferWriter) + element.WriteTo(jsonWriter) + ) + JsonSerializer.Deserialize<'T>(bufferWriter.WrittenSpan, defaultArg options null) +#endif diff --git a/src/FsCodec.SystemTextJson/Options.fs b/src/FsCodec.SystemTextJson/Options.fs new file mode 100755 index 0000000..b9b53a0 --- /dev/null +++ b/src/FsCodec.SystemTextJson/Options.fs @@ -0,0 +1,59 @@ +namespace FsCodec.SystemTextJson + +open System +open System.Runtime.InteropServices +open System.Text.Json +open System.Text.Json.Serialization + +type Options private () = + + static let defaultConverters : JsonConverter[] = [| Converters.JsonOptionConverter() |] + + /// Creates a default set of serializer options used by Json serialization. When used with no args, same as `JsonSerializerOptions()` + static member CreateDefault + ( [] converters : JsonConverter[], + /// Use multi-line, indented formatting when serializing JSON; defaults to false. + [] ?indent : bool, + /// Render idiomatic camelCase for PascalCase items by using `PropertyNamingPolicy = CamelCase`. Defaults to false. + [] ?camelCase : bool, + /// Ignore null values in input data, don't render fields with null values; defaults to `false`. + [] ?ignoreNulls : bool, + /// Drop escaping of HTML-sensitive characters. defaults to `false`. + [] ?unsafeRelaxedJsonEscaping : bool) = + + let indent = defaultArg indent false + let camelCase = defaultArg camelCase false + let ignoreNulls = defaultArg ignoreNulls false + let unsafeRelaxedJsonEscaping = defaultArg unsafeRelaxedJsonEscaping false + let options = JsonSerializerOptions() + if converters <> null then converters |> Array.iter options.Converters.Add + if indent then options.WriteIndented <- true + if camelCase then options.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase; options.DictionaryKeyPolicy <- JsonNamingPolicy.CamelCase + if ignoreNulls then options.IgnoreNullValues <- true + if unsafeRelaxedJsonEscaping then options.Encoder <- System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + options + + /// Opinionated helper that creates serializer settings that represent good defaults for F#
+ /// - Always prepends `[JsonOptionConverter()]` to any converters supplied
+ /// - no camel case conversion - assumption is you'll use records with camelCased names
+ /// - renders values with `UnsafeRelaxedJsonEscaping` - i.e. minimal escaping as per `NewtonsoftJson`
+ /// 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()] will be prepended and/or be used as a default + [] converters : JsonConverter[], + /// Use multi-line, indented formatting when serializing JSON; defaults to false. + [] ?indent : bool, + /// Render idiomatic camelCase for PascalCase items by using `PropertyNamingPolicy = CamelCase`. + /// Defaults to false on basis that you'll use record and tuple field names that are camelCase (but thus not `CLSCompliant`). + [] ?camelCase : bool, + /// Ignore null values in input data, don't render fields with null values; defaults to `false`. + [] ?ignoreNulls : bool, + /// Drop escaping of HTML-sensitive characters. defaults to `true`. + [] ?unsafeRelaxedJsonEscaping : bool) = + + Options.CreateDefault( + converters = (match converters with null | [||] -> defaultConverters | xs -> Array.append defaultConverters xs), + ?ignoreNulls = ignoreNulls, + ?indent = indent, + ?camelCase = camelCase, + unsafeRelaxedJsonEscaping = defaultArg unsafeRelaxedJsonEscaping true) diff --git a/src/FsCodec.SystemTextJson/Pickler.fs b/src/FsCodec.SystemTextJson/Pickler.fs new file mode 100755 index 0000000..12638c6 --- /dev/null +++ b/src/FsCodec.SystemTextJson/Pickler.fs @@ -0,0 +1,60 @@ +namespace FsCodec.SystemTextJson + +open System +open System.Text.Json + +[] +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) + +[] +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 * 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 +[] +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 diff --git a/src/FsCodec.SystemTextJson/Serdes.fs b/src/FsCodec.SystemTextJson/Serdes.fs new file mode 100755 index 0000000..a980509 --- /dev/null +++ b/src/FsCodec.SystemTextJson/Serdes.fs @@ -0,0 +1,36 @@ +namespace FsCodec.SystemTextJson + +open System.Runtime.InteropServices +open System.Text.Json + +/// Serializes to/from strings using the Options arising from a call to Options.Create() +type Serdes private () = + + static let defaultOptions = lazy Options.Create() + static let indentOptions = lazy Options.Create(indent = true) + + /// Serializes given value to a JSON string. + static member Serialize<'T> + ( /// Value to serialize. + value : 'T, + /// Use indentation when serializing JSON. Defaults to false. + [] ?indent : bool) : string = + let options = (if defaultArg indent false then indentOptions else defaultOptions).Value + JsonSerializer.Serialize(value, options) + + /// Serializes given value to a JSON string with custom options + static member Serialize<'T> + ( /// Value to serialize. + value : 'T, + /// Options to use (use other overload to use Options.Create() profile) + options : JsonSerializerOptions) : string = + JsonSerializer.Serialize(value, options) + + /// Deserializes value of given type from JSON string. + static member Deserialize<'T> + ( /// Json string to deserialize. + json : string, + /// Options to use (defaults to Options.Create() profile) + [] ?options : JsonSerializerOptions) : 'T = + let settings = match options with None -> defaultOptions.Value | Some x -> x + JsonSerializer.Deserialize<'T>(json, settings) diff --git a/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs b/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs new file mode 100755 index 0000000..2fbf483 --- /dev/null +++ b/src/FsCodec.SystemTextJson/TypeSafeEnumConverter.fs @@ -0,0 +1,54 @@ +namespace FsCodec.SystemTextJson + +open System +open System.Collections.Generic +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.tryGetUnion t + |> Option.exists (fun u -> u.cases |> Seq.forall (fun case -> case.GetFields().Length = 0)) + let isTypeSafeEnum : Type -> bool = memoize _isTypeSafeEnum + + let tryParseT (t : Type) predicate = + match Union.tryGetUnion t with + | None -> invalidArg "t" "Type must be a FSharpUnion." + | Some u -> + u.cases + |> Array.tryFindIndex (fun c -> predicate c.Name) + |> Option.map (fun tag -> u.caseConstructor.[tag] [||]) + // TOCONSIDER memoize and/or push into `Union` https://github.com/jet/FsCodec/pull/41#discussion_r394473137 + let tryParse<'T> (str : string) = tryParseT typeof<'T> ((=) str) |> Option.map (fun e -> e :?> 'T) + + let parseT (t : Type) (str : string) = + match tryParseT t ((=) str) with + | Some e -> e + | None -> + // Keep exception compat, but augment with a meaningful message. + raise (KeyNotFoundException(sprintf "Could not find case '%s' for type '%s'" str t.FullName)) + let parse<'T> (str : string) = parseT typeof<'T> str :?> 'T + + let toString<'t> (x : 't) = + let union = Union.tryGetUnion (typeof<'t>) |> Option.get + let tag = union.tagReader (box x) + // TOCONSIDER memoize and/or push into `Union` https://github.com/jet/FsCodec/pull/41#discussion_r394473137 + union.cases.[tag].Name + +/// Maps strings to/from Union cases; refuses to convert for values not in the Union +type TypeSafeEnumConverter<'T>() = + inherit Serialization.JsonConverter<'T>() + + override __.CanConvert(t : Type) = + t = typedefof<'T> && TypeSafeEnum.isTypeSafeEnum typedefof<'T> + + override __.Write(writer, value, _options) = + let str = TypeSafeEnum.toString value + writer.WriteStringValue str + + override __.Read(reader, _t, _options) = + if reader.TokenType <> JsonTokenType.String then + sprintf "Unexpected token when reading TypeSafeEnum: %O" reader.TokenType |> JsonException |> raise + let str = reader.GetString() + TypeSafeEnum.parse<'T> str diff --git a/src/FsCodec.SystemTextJson/UnionConverter.fs b/src/FsCodec.SystemTextJson/UnionConverter.fs new file mode 100755 index 0000000..9832446 --- /dev/null +++ b/src/FsCodec.SystemTextJson/UnionConverter.fs @@ -0,0 +1,27 @@ +namespace FsCodec.SystemTextJson + +open FSharp.Reflection +open System + +[] +type private Union = + { + cases: UnionCaseInfo[] + tagReader: obj -> int + fieldReader: (obj -> obj[])[] + caseConstructor: (obj[] -> obj)[] + } + +module private Union = + let private _tryGetUnion t = + if not (FSharpType.IsUnion(t, true)) then + None + else + let cases = FSharpType.GetUnionCases(t, true) + { + cases = cases + 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)) + } |> Some + let tryGetUnion : Type -> Union option = memoize _tryGetUnion diff --git a/tests/FsCodec.NewtonsoftJson.Tests/FsCodec.NewtonsoftJson.Tests.fsproj b/tests/FsCodec.NewtonsoftJson.Tests/FsCodec.NewtonsoftJson.Tests.fsproj index 28f8352..e289859 100644 --- a/tests/FsCodec.NewtonsoftJson.Tests/FsCodec.NewtonsoftJson.Tests.fsproj +++ b/tests/FsCodec.NewtonsoftJson.Tests/FsCodec.NewtonsoftJson.Tests.fsproj @@ -7,7 +7,6 @@ - @@ -15,12 +14,11 @@ - - - + + - + diff --git a/tests/FsCodec.NewtonsoftJson.Tests/VerbatimUtf8ConverterTests.fs b/tests/FsCodec.NewtonsoftJson.Tests/VerbatimUtf8ConverterTests.fs index 76f4ef6..78e7c35 100644 --- a/tests/FsCodec.NewtonsoftJson.Tests/VerbatimUtf8ConverterTests.fs +++ b/tests/FsCodec.NewtonsoftJson.Tests/VerbatimUtf8ConverterTests.fs @@ -49,37 +49,39 @@ type [] i: int64 n: int64 e: Event[] } - -let defaultSettings = Settings.CreateDefault() +let mkBatch (encoded : FsCodec.IEventData) : Batch = + { p = "streamName"; id = string 0; i = -1L; n = -1L; _etag = null + e = [| { t = DateTimeOffset.MinValue; c = encoded.EventType; d = encoded.Data; m = null } |] } #nowarn "1182" // From hereon in, we may have some 'unused' privates (the tests) -type VerbatimUtf8Tests() = - let eventCodec = Codec.Create() +module VerbatimUtf8Tests = // not a module or CI will fail for net461 + + let eventCodec = Codec.Create() - [] - let ``encodes correctly`` () = - let encoded = eventCodec.Encode(None, A { embed = "\"" }) - let e : Batch = - { p = "streamName"; id = string 0; i = -1L; n = -1L; _etag = null - e = [| { t = DateTimeOffset.MinValue; c = encoded.EventType; d = encoded.Data; m = null } |] } + let [] ``encodes correctly`` () = + let input = Union.A { embed = "\"" } + let encoded = eventCodec.Encode(None, input) + let e : Batch = mkBatch encoded let res = JsonConvert.SerializeObject(e) test <@ res.Contains """"d":{"embed":"\""}""" @> + let des = JsonConvert.DeserializeObject(res) + let loaded = FsCodec.Core.TimelineEvent.Create(-1L, des.e.[0].c, des.e.[0].d) + let decoded = eventCodec.TryDecode loaded |> Option.get + input =! decoded + let defaultSettings = Settings.CreateDefault() let defaultEventCodec = Codec.Create(defaultSettings) - let [] ``round-trips diverse bodies correctly`` (x: U) = + let [] ``round-trips diverse bodies correctly`` (x: U) = let encoded = defaultEventCodec.Encode(None,x) - let e : Batch = - { p = "streamName"; id = string 0; i = -1L; n = -1L; _etag = null - e = [| { t = DateTimeOffset.MinValue; c = encoded.EventType; d = encoded.Data; m = null } |] } + let e : Batch = mkBatch encoded let ser = JsonConvert.SerializeObject(e, defaultSettings) let des = JsonConvert.DeserializeObject(ser, defaultSettings) let loaded = FsCodec.Core.TimelineEvent.Create(-1L, des.e.[0].c, des.e.[0].d) let decoded = defaultEventCodec.TryDecode loaded |> Option.get x =! decoded - // NB while this aspect works, we don't support it as it gets messy when you then use the VerbatimUtf8Converter // https://github.com/JamesNK/Newtonsoft.Json/issues/862 // doesnt apply to this case let [] ``Codec does not fall prey to Date-strings being mutilated`` () = let x = ES { embed = "2016-03-31T07:02:00+07:00" } @@ -97,6 +99,7 @@ type VerbatimUtf8Tests() = // test <@ x = decoded @> module VerbatimUtf8NullHandling = + type [] EventHolderWithAndWithoutRequired = { /// Event body, as UTF-8 encoded JSON ready to be injected directly into the Json being rendered [)>] @@ -118,4 +121,4 @@ module VerbatimUtf8NullHandling = let ser = JsonConvert.SerializeObject(e) let des = JsonConvert.DeserializeObject(ser) test <@ ((e.m = null || e.m.Length = 0) && (des.m = null)) || System.Linq.Enumerable.SequenceEqual(e.m, des.m) @> - test <@ ((e.d = null || e.d.Length = 0) && (des.d = null)) || System.Linq.Enumerable.SequenceEqual(e.d, des.d) @> \ No newline at end of file + test <@ ((e.d = null || e.d.Length = 0) && (des.d = null)) || System.Linq.Enumerable.SequenceEqual(e.d, des.d) @> diff --git a/tests/FsCodec.SystemTextJson.Tests/CodecTests.fs b/tests/FsCodec.SystemTextJson.Tests/CodecTests.fs new file mode 100644 index 0000000..61c7462 --- /dev/null +++ b/tests/FsCodec.SystemTextJson.Tests/CodecTests.fs @@ -0,0 +1,69 @@ +module FsCodec.SystemTextJson.Tests.CodecTests + +open FsCodec.SystemTextJson // bring in ToByteArrayCodec etc extension methods +open System.Text.Json +open FsCheck.Xunit +open Swensen.Unquote + +type Embedded = { embed : string } +type EmbeddedWithOption = { embed : string; opt : string option } +type Union = + | A of Embedded + | B of Embedded + | AO of EmbeddedWithOption + | BO of EmbeddedWithOption + interface TypeShape.UnionContract.IUnionContract + +let ignoreNullOptions = FsCodec.SystemTextJson.Options.Create(ignoreNulls = true) +let elementEncoder : TypeShape.UnionContract.IEncoder = + FsCodec.SystemTextJson.Core.JsonElementEncoder(ignoreNullOptions) :> _ + +let eventCodec = FsCodec.SystemTextJson.Codec.Create(ignoreNullOptions) +let doubleHopCodec = eventCodec.ToByteArrayCodec().ToJsonElementCodec() + +[] +type Envelope = { d : JsonElement } + +let [] roundtrips value = + let eventType, embedded = + match value with + | A e -> "A", Choice1Of2 e + | AO e -> "AO",Choice2Of2 e + | B e -> "B", Choice1Of2 e + | BO e -> "BO",Choice2Of2 e + let encoded = + match embedded with + | Choice1Of2 e -> elementEncoder.Encode e + | Choice2Of2 eo -> elementEncoder.Encode eo + let enveloped = { d = encoded } + + // the options should be irrelevant, but use the defaults (which would add nulls in that we don't want if it was leaking) + let ser = FsCodec.SystemTextJson.Serdes.Serialize enveloped + + match embedded with + | Choice1Of2 { embed = null } + | Choice2Of2 { embed = null; opt = None } -> + test <@ ser = """{"d":{}}""" @> + | Choice2Of2 { embed = null; opt = Some null } -> + test <@ ser = """{"d":{"opt":null}}""" @> + | Choice2Of2 { embed = null } -> + test <@ ser.StartsWith("""{"d":{"opt":""") @> + | Choice2Of2 { opt = x } -> + test <@ ser.StartsWith """{"d":{"embed":""" && ser.Contains "opt" = Option.isSome x @> + | Choice1Of2 _ -> + test <@ ser.StartsWith """{"d":{"embed":""" && not (ser.Contains "\"opt\"") @> + + let des = FsCodec.SystemTextJson.Serdes.Deserialize ser + let wrapped = FsCodec.Core.TimelineEvent.Create(-1L, eventType, des.d) + let decoded = eventCodec.TryDecode wrapped |> Option.get + + let expected = + match value with + | AO ({ opt = Some null } as v) -> AO { v with opt = None } + | BO ({ opt = Some null } as v) -> BO { v with opt = None } + | x -> x + test <@ expected = decoded @> + + // Also validate the adapters work when put in series (NewtonsoftJson tests are responsible for covering the individual hops) + let decodedDoubleHop = doubleHopCodec.TryDecode wrapped |> Option.get + test <@ expected = decodedDoubleHop @> diff --git a/tests/FsCodec.SystemTextJson.Tests/Examples.fsx b/tests/FsCodec.SystemTextJson.Tests/Examples.fsx new file mode 100755 index 0000000..90f465d --- /dev/null +++ b/tests/FsCodec.SystemTextJson.Tests/Examples.fsx @@ -0,0 +1,262 @@ +// Compile the fsproj by either a) right-clicking or b) typing +// dotnet build tests/FsCodec.SystemTextJson.Tests before attempting to send this to FSI with Alt-Enter + +#if VISUALSTUDIO +#r "netstandard" +#endif +#I "bin/Debug/netcoreapp3.1" +//#r "System.Text.Json.dll" +#r "Serilog.dll" +#r "Serilog.Sinks.Console.dll" +#r "TypeShape.dll" +#r "FsCodec.dll" +#r "FsCodec.SystemTextJson.dll" +#r "FSharp.UMX.dll" + +open FsCodec.SystemTextJson +open System.Text.Json +open System.Text.Json.Serialization // JsonConverter is in that namespace +open System + +module Contract = + + type Item = { value : string option } + // implies default options from Options.Create(), which includes OptionConverter + let serialize (x : Item) : string = FsCodec.SystemTextJson.Serdes.Serialize x + // implies default options from Options.Create(), which includes OptionConverter + let deserialize (json : string) = FsCodec.SystemTextJson.Serdes.Deserialize json + +module Contract2 = + + type TypeThatRequiresMyCustomConverter = { mess : int } + type MyCustomConverter() = inherit JsonPickler() override __.Read(_,_) = "" override __.Write(_,_,_) = () + type Item = { value : string option; other : TypeThatRequiresMyCustomConverter } + /// Options to be used within this contract; note JsonOptionConverter is also included by default + let options = FsCodec.SystemTextJson.Options.Create(converters = [| MyCustomConverter() |]) + let serialize (x : Item) = FsCodec.SystemTextJson.Serdes.Serialize(x, options) + let deserialize (json : string) : Item = FsCodec.SystemTextJson.Serdes.Deserialize(json, options) + +let inline ser x = Serdes.Serialize(x) +let inline des<'t> x = Serdes.Deserialize<'t>(x) + +(* Global vs local Converters + +It's recommended to avoid global converters, for at least the following reasons: +- they're less efficient +- they're more easy to get wrong if you have the wrong policy in place +- Explicit is better than implicit *) +type GuidConverter() = + inherit JsonIsomorphism() + override __.Pickle g = g.ToString "N" + override __.UnPickle g = Guid.Parse g + +type WithEmbeddedGuid = { a: string; [)>] b: Guid } + +ser { a = "testing"; b = Guid.Empty } +// {"a":"testing","b":"00000000000000000000000000000000"} + +ser Guid.Empty +// "00000000-0000-0000-0000-000000000000" + +let options = Options.Create(converters = [| GuidConverter() |]) +Serdes.Serialize(Guid.Empty, options) +// 00000000000000000000000000000000 + +(* TypeSafeEnumConverter basic usage *) + +[>)>] +type Outcome = Joy | Pain | Misery + +type Message = { name: string option; outcome: Outcome } + +let value = { name = Some null; outcome = Joy} +ser value +// {"name":null,"outcome":"Joy"} + +des """{"name":null,"outcome":"Joy"}""" +// val it : Message = {name = None; outcome = Joy;} + +// By design, we throw when a value is unknown. Often this is the correct design. +// If, and only if, your software can do something useful with catch-all case, see the technique in `OutcomeWithOther` +try des """{"name":null,"outcome":"Discomfort"}""" with e -> printf "%A" e; Unchecked.defaultof +// System.Collections.Generic.KeyNotFoundException: Could not find case 'Discomfort' for type 'FSI_0012+Outcome' + +(* TypeSafeEnumConverter fallback + +While, in general, one wants to version contracts such that invalid values simply don't arise, + in some cases you want to explicitly handle out of range values. +Here we implement a converter as a JsonIsomorphism to achieve such a mapping *) + +[)>] +type OutcomeWithOther = Joy | Pain | Misery | Other +and OutcomeWithCatchAllConverter() = + inherit JsonIsomorphism() + override __.Pickle v = + TypeSafeEnum.toString v + + override __.UnPickle json = + json + |> TypeSafeEnum.tryParse + |> Option.defaultValue Other + +type Message2 = { name: string option; outcome: OutcomeWithOther } + +let value2 = { name = Some null; outcome = Joy} +ser value2 +// {"name":null,"outcome":"Joy"} + +des """{"name":null,"outcome":"Joy"}""" +// val it : Message = {name = None; outcome = Joy;} + +des """{"name":null,"outcome":"Discomfort"}""" +// val it : Message = {name = None; outcome = Other;} + +(* Illustrating usage of IEventCodec and its accompanying active patterns *) + +module EventCodec = + + /// Uses the supplied codec to decode the supplied event record `x` (iff at LogEventLevel.Debug, detail fails to `log` citing the `stream` and content) + let tryDecode (codec : FsCodec.IEventCodec<_, _, _>) (log : Serilog.ILogger) streamName (x : FsCodec.ITimelineEvent) = + match codec.TryDecode x with + | None -> + if log.IsEnabled Serilog.Events.LogEventLevel.Debug then + log.ForContext("event", string x.Data, true) + .Debug("Codec {type} Could not decode {eventType} in {stream}", codec.GetType().FullName, x.EventType, streamName) + None + | x -> x + +open FSharp.UMX + +type ClientId = string +and [] clientId +module ClientId = + let parse (str : string) : ClientId = % str + let toString (value : ClientId) : string = % value + let (|Parse|) = parse + +module Events = + + // By convention, each contract defines a 'category' used as the first part of the stream name (e.g. `"Favorites-ClientA"`) + let [] CategoryId = "Favorites" + + /// Pattern to determine whether a given {category}-{aggregateId} StreamName represents the stream associated with this Aggregate + /// Yields a strongly typed id from the aggregateId if the Category does match + let (|MatchesCategory|_|) = function + | FsCodec.StreamName.CategoryAndId (CategoryId, ClientId.Parse clientId) -> Some clientId + | _ -> None + + type Added = { item : string } + type Removed = { name : string } + type Event = + | Added of Added + | Removed of Removed + interface TypeShape.UnionContract.IUnionContract + + let codec = FsCodec.SystemTextJson.Codec.Create() + let (|Decode|_|) stream = EventCodec.tryDecode codec Serilog.Log.Logger stream + + /// Yields decoded event and relevant strongly typed ids if the category of the Stream Name is correct + let (|Match|_|) (streamName, span) = + match streamName, span with + | MatchesCategory clientId, (Decode streamName event) -> Some (clientId, event) + | _ -> None + +open FsCodec + +let enc (s : string) = Serdes.Deserialize(s) +let events = [ + StreamName.parse "Favorites-ClientA", FsCodec.Core.TimelineEvent.Create(0L, "Added", enc """{ "item": "a" }""") + StreamName.parse "Favorites-ClientB", FsCodec.Core.TimelineEvent.Create(0L, "Added", enc """{ "item": "b" }""") + StreamName.parse "Favorites-ClientA", FsCodec.Core.TimelineEvent.Create(1L, "Added", enc """{ "item": "b" }""") + StreamName.parse "Favorites-ClientB", FsCodec.Core.TimelineEvent.Create(1L, "Added", enc """{ "item": "a" }""") + StreamName.parse "Favorites-ClientB", FsCodec.Core.TimelineEvent.Create(2L, "Removed", enc """{ "item": "a" }""") + StreamName.create "Favorites" "ClientB", FsCodec.Core.TimelineEvent.Create(3L, "Exported", enc """{ "count": 2 }""") + StreamName.parse "Misc-x", FsCodec.Core.TimelineEvent.Create(0L, "Dummy", enc """{ "item": "z" }""") +] + +// Explicit matching +let runCodec () = + for stream, event in events do + match stream, event with + | StreamName.CategoryAndId (Events.CategoryId, ClientId.Parse id), (Events.Decode stream e) -> + printfn "Client %s, event %A" (ClientId.toString id) e + | StreamName.CategoryAndId (cat, id), e -> + printfn "Unhandled Event: Category %s, Id %s, Index %d, Event: %A " cat id e.Index e.EventType + +runCodec () + +let runCodecCleaner () = + for stream, event in events do + match stream, event with + | Events.Match (clientId, event) -> + printfn "Client %s, event %A" (ClientId.toString clientId) event + | FsCodec.StreamName.CategoryAndId (cat, id), e -> + printfn "Unhandled Event: Category %s, Id %s, Index %d, Event: %A " cat id e.Index e.EventType + +runCodecCleaner () + +// Switch on debug logging to get detailed information about events that don't match (which has no significant perf cost when not switched on) +open Serilog +open Serilog.Events +let outputTemplate = "{Message} {Properties}{NewLine}" +Serilog.Log.Logger <- + LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Console(LogEventLevel.Debug, outputTemplate=outputTemplate) + .CreateLogger() + +runCodec () +(* +Client ClientA, event Added {item = "a";} +Client ClientB, event Added {item = "b";} +Client ClientA, event Added {item = "b";} +Client ClientB, event Added {item = "a";} +Client ClientB, event Removed {name = null;} +Codec "" Could not decode "Exported" in "Favorites-ClientB" {event="{ \"count\": 2 }"} +Unhandled Event: Category Favorites, Id ClientB, Index 3, Event: "Exported" +Unhandled Event: Category Misc, Id x, Index 0, Event: "Dummy" +*) + +(* Decoding contextual information + + Events arriving from a store (e.g. Equinox etc) or source (e.g. Propulsion) bear contextual information. + Where relevant, a decoding process may want to extract such context alongside mapping the base information. +*) + +module EventsWithMeta = + + type EventWithMeta = int64 * DateTimeOffset * Events.Event + + let codec = + let up (raw : FsCodec.ITimelineEvent, contract : Events.Event) : EventWithMeta = + raw.Index, raw.Timestamp, contract + let down ((_index, timestamp, event) : EventWithMeta) = + event, None, Some timestamp + FsCodec.SystemTextJson.Codec.Create(up, down) + + let (|Decode|_|) stream event : EventWithMeta option = EventCodec.tryDecode codec Serilog.Log.Logger stream event + + let (|Match|_|) (streamName, span) = + match streamName, span with + | Events.MatchesCategory clientId, (Decode streamName event) -> Some (clientId, event) + | _ -> None + +let runWithContext () = + for stream, event in events do + match stream, event with + | EventsWithMeta.Match (clientId, (index, ts, e)) -> + printfn "Client %s index %d time %O event %A" (ClientId.toString clientId) index (ts.ToString "u") e + | FsCodec.StreamName.CategoryAndId (cat, id), e -> + printfn "Unhandled Event: Category %s, Id %s, Index %d, Event: %A " cat id e.Index e.EventType + +runWithContext () +(* +Client ClientA index 0 time 2020-01-13 09:44:37Z event Added {item = "a";} +Client ClientB index 0 time 2020-01-13 09:44:37Z event Added {item = "b";} +Client ClientA index 1 time 2020-01-13 09:44:37Z event Added {item = "b";} +Client ClientB index 1 time 2020-01-13 09:44:37Z event Added {item = "a";} +Client ClientB index 2 time 2020-01-13 09:44:37Z event Removed {name = null;} +Codec "" Could not decode "Exported" in "Favorites-ClientB" {event="{ \"count\": 2 }"} +Unhandled Event: Category Favorites, Id ClientB, Index 3, Event: "Exported" +Unhandled Event: Category Misc, Id x, Index 0, Event: "Dummy" +*) diff --git a/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj b/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj new file mode 100644 index 0000000..25fabfd --- /dev/null +++ b/tests/FsCodec.SystemTextJson.Tests/FsCodec.SystemTextJson.Tests.fsproj @@ -0,0 +1,35 @@ + + + + netcoreapp3.1 + 5 + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/FsCodec.SystemTextJson.Tests/InteropTests.fs b/tests/FsCodec.SystemTextJson.Tests/InteropTests.fs new file mode 100644 index 0000000..2fed622 --- /dev/null +++ b/tests/FsCodec.SystemTextJson.Tests/InteropTests.fs @@ -0,0 +1,48 @@ +/// Covers interop with stores that manage event bodies as byte[] +module FsCodec.SystemTextJson.Tests.InteropTests + +open FsCheck.Xunit +open Newtonsoft.Json +open Swensen.Unquote +open System +open Xunit + +type Batch = FsCodec.NewtonsoftJson.Tests.VerbatimUtf8ConverterTests.Batch +type Union = FsCodec.NewtonsoftJson.Tests.VerbatimUtf8ConverterTests.Union +let mkBatch = FsCodec.NewtonsoftJson.Tests.VerbatimUtf8ConverterTests.mkBatch + +let indirectCodec = FsCodec.SystemTextJson.Codec.Create() |> FsCodec.SystemTextJson.InteropExtensions.ToByteArrayCodec +let [] ``encodes correctly`` () = + let input = Union.A { embed = "\"" } + let encoded = indirectCodec.Encode(None, input) + let e : Batch = mkBatch encoded + let res = JsonConvert.SerializeObject(e) + test <@ res.Contains """"d":{"embed":"\""}""" @> + let des = JsonConvert.DeserializeObject(res) + let loaded = FsCodec.Core.TimelineEvent.Create(-1L, des.e.[0].c, des.e.[0].d) + let decoded = indirectCodec.TryDecode loaded |> Option.get + input =! decoded + +type EmbeddedString = { embed : string } +type EmbeddedDateTimeOffset = { embed : DateTimeOffset } +type U = + // | S of string // Opens up some edge cases wrt handling missing/empty/null `d` fields in stores, but possible if you have time to shave that yak! + | EDto of EmbeddedDateTimeOffset + | ES of EmbeddedString + | N + interface TypeShape.UnionContract.IUnionContract + +let defaultSettings = FsCodec.NewtonsoftJson.Settings.CreateDefault() // Test without converters, as that's what Equinox.Cosmos will do +let defaultEventCodec = FsCodec.NewtonsoftJson.Codec.Create(defaultSettings) +let indirectCodecU = FsCodec.SystemTextJson.Codec.Create() |> FsCodec.SystemTextJson.InteropExtensions.ToByteArrayCodec + +let [] ``round-trips diverse bodies correctly`` (x: U, encodeDirect, decodeDirect) = + let encoder = if encodeDirect then defaultEventCodec else indirectCodecU + let decoder = if decodeDirect then defaultEventCodec else indirectCodecU + let encoded = encoder.Encode(None,x) + let e : Batch = mkBatch encoded + let ser = JsonConvert.SerializeObject(e, defaultSettings) + let des = JsonConvert.DeserializeObject(ser, defaultSettings) + let loaded = FsCodec.Core.TimelineEvent.Create(-1L, des.e.[0].c, des.e.[0].d) + let decoded = decoder.TryDecode loaded |> Option.get + x =! decoded diff --git a/tests/FsCodec.SystemTextJson.Tests/PicklerTests.fs b/tests/FsCodec.SystemTextJson.Tests/PicklerTests.fs new file mode 100644 index 0000000..ced8a08 --- /dev/null +++ b/tests/FsCodec.SystemTextJson.Tests/PicklerTests.fs @@ -0,0 +1,61 @@ +module FsCodec.SystemTextJson.Tests.PicklerTests + +open FsCodec.SystemTextJson +open FsCodec.SystemTextJson.Converters +open Swensen.Unquote +open System +open System.Text.Json +open Xunit + +// NB Feel free to ignore this opinion and copy the 4 lines into your own globals - the pinning test will remain here +/// +/// Renders Guids without dashes. +/// +/// +/// 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. +/// +type GuidConverter() = + inherit JsonIsomorphism() + override __.Pickle g = g.ToString "N" + override __.UnPickle g = Guid.Parse g + +type WithEmbeddedGuid = { a: string; [)>] b: Guid } + +type Configs() as this = + inherit TheoryData() + do this.Add(Options.CreateDefault()) // 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 [)>] ``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 [] ``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 <@ Serdes.Deserialize resNoDashes @> + + // With the converter, things roundtrip either way + for result in [defaultHandlingHasDashes; resNoDashes] do + let des = Serdes.Deserialize(result, optionsWithConverter) + test <@ value= des @> diff --git a/tests/FsCodec.SystemTextJson.Tests/SerdesTests.fs b/tests/FsCodec.SystemTextJson.Tests/SerdesTests.fs new file mode 100644 index 0000000..9f766c2 --- /dev/null +++ b/tests/FsCodec.SystemTextJson.Tests/SerdesTests.fs @@ -0,0 +1,117 @@ +module FsCodec.SystemTextJson.Tests.SerdesTests + +open System.Collections.Generic +open FsCodec.SystemTextJson +open Swensen.Unquote +open Xunit + +type Record = { a : int } + +type RecordWithOption = { a : int; b : string option } + +/// Characterization tests for OOTB JSON.NET +/// The aim here is to characterize the gaps that we'll shim; we only want to do that as long as it's actually warranted +module StjCharacterization = + let ootbOptions = Options.CreateDefault() + + let [] ``OOTB STJ records`` () = + // Ver 5.x includes standard support for calling a single ctor (4.x required a custom implementation) + let value = { a = 1 } + let ser = Serdes.Serialize(value, ootbOptions) + test <@ ser = """{"a":1}""" @> + + let res = Serdes.Deserialize(ser, ootbOptions) + test <@ res = value @> + + let [] ``OOTB STJ options`` () = + let value = { a = 1; b = Some "str" } + let ser = Serdes.Serialize(value, ootbOptions) + test <@ ser = """{"a":1,"b":{"Value":"str"}}""" @> + + let correctSer = """{"a":1,"b":"str"}""" + raisesWith <@ Serdes.Deserialize(correctSer, ootbOptions) @> + <| fun e -> <@ e.Message.Contains "The JSON value could not be converted to Microsoft.FSharp.Core.FSharpOption`1[System.String]" @> + + let [] ``OOTB STJ lists`` () = + let value = [ "A"; "B" ] + let ser = Serdes.Serialize(value, ootbOptions) + test <@ ser = """["A","B"]""" @> + + let correctSer = """["A,"B"]""" + raisesWith <@ Serdes.Deserialize(correctSer, ootbOptions) @> + <| fun e -> <@ e.Message.Contains "Deserialization of reference types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'Microsoft.FSharp.Collections.FSharpList`1[System.String]" @> + + // System.Text.Json's JsonSerializerOptions by default escapes HTML-sensitive characters when generating JSON strings + // while this arguably makes sense as a default + // - it's not particularly relevant for event encodings + // - and is not in alignment with the FsCodec.NewtonsoftJson default options + // see https://github.com/dotnet/runtime/issues/28567#issuecomment-53581752 for lowdown + let asRequiredForExamples : System.Text.Json.Serialization.JsonConverter [] = [| Converters.JsonOptionConverter() |] + type OverescapedOptions() as this = + inherit TheoryData() + + do // OOTB System.Text.Json over-escapes HTML-sensitive characters - `CreateDefault` honors this + this.Add(Options.CreateDefault(converters = asRequiredForExamples)) // the value we use here requires two custom Converters + // Options.Create provides a simple way to override it + this.Add(Options.Create(unsafeRelaxedJsonEscaping = false)) + let [)>] ``provides various ways to use HTML-escaped encoding``(opts : System.Text.Json.JsonSerializerOptions) = + let value = { a = 1; b = Some "\"" } + let ser = Serdes.Serialize(value, opts) + test <@ ser = """{"a":1,"b":"\u0022"}""" @> + let des = Serdes.Deserialize(ser, opts) + test <@ value = des @> + +(* Serdes + default Options behavior, i.e. the stuff we do *) + +let [] records () = + let value = { a = 1 } + let res = Serdes.Serialize value + test <@ res = """{"a":1}""" @> + let des = Serdes.Deserialize res + test <@ value = des @> + +let [] arrays () = + let value = [|"A"; "B"|] + let res = Serdes.Serialize value + test <@ res = """["A","B"]""" @> + let des = Serdes.Deserialize res + test <@ value = des @> + +let [] options () = + let value : RecordWithOption = { a = 1; b = Some "str" } + let ser = Serdes.Serialize value + test <@ ser = """{"a":1,"b":"str"}""" @> + let des = Serdes.Deserialize ser + test <@ value = des @> + +// For maps, represent the value as an IDictionary<'K, 'V> or Dictionary and parse into a model as appropriate +let [] maps () = + let value = Map(seq { "A",1; "b",2 }) + let ser = Serdes.Serialize> value + test <@ ser = """{"A":1,"b":2}""" @> + let des = Serdes.Deserialize> ser + test <@ value = Map.ofSeq (des |> Seq.map (|KeyValue|)) @> + +type RecordWithArrayOption = { str : string; arr : string[] option } +type RecordWithArrayVOption = { str : string; arr : string[] voption } + +// Instead of using `list`s, it's recommended to use arrays as one would in C# +// where there's a possibility of deserializing a missing or null value, that hence maps to a `null` value +// A supported way of managing this is by wrapping the array in an `option` +let [] ``array options`` () = + let value = [|"A"; "B"|] + let res = Serdes.Serialize value + test <@ res = """["A","B"]""" @> + let des = Serdes.Deserialize res + test <@ Some value = des @> + let des = Serdes.Deserialize "null" + test <@ None = des @> + let des = Serdes.Deserialize "{}" + test <@ { str = null; arr = ValueNone } = des @> + +let [] ``Switches off the HTML over-escaping mechanism`` () = + let value = { a = 1; b = Some "\"+" } + let ser = Serdes.Serialize value + test <@ ser = """{"a":1,"b":"\"+"}""" @> + let des = Serdes.Deserialize ser + test <@ value = des @> diff --git a/tests/FsCodec.SystemTextJson.Tests/TypeSafeEnumConverterTests.fs b/tests/FsCodec.SystemTextJson.Tests/TypeSafeEnumConverterTests.fs new file mode 100644 index 0000000..33beb79 --- /dev/null +++ b/tests/FsCodec.SystemTextJson.Tests/TypeSafeEnumConverterTests.fs @@ -0,0 +1,46 @@ +module FsCodec.SystemTextJson.Tests.TypeSafeEnumConverterTests + +open FsCodec.SystemTextJson +open System +open System.Collections.Generic +open System.Text.Json +open Swensen.Unquote +open Xunit + +type Outcome = Joy | Pain | Misery + +let [] happy () = + test <@ box Joy = TypeSafeEnum.parseT (typeof) "Joy" @> + test <@ Joy = TypeSafeEnum.parse "Joy" @> + test <@ box Joy = TypeSafeEnum.parseT (typeof) "Joy" @> + test <@ None = TypeSafeEnum.tryParse "Wat" @> + raises <@ TypeSafeEnum.parse "Wat" @> + + let optionsWithOutcomeConverter = Options.Create(TypeSafeEnumConverter()) + test <@ Joy = Serdes.Deserialize("\"Joy\"", optionsWithOutcomeConverter) @> + test <@ Some Joy = Serdes.Deserialize("\"Joy\"", optionsWithOutcomeConverter) @> + raises <@ Serdes.Deserialize("\"Confusion\"", optionsWithOutcomeConverter) @> + raises <@ Serdes.Deserialize "1" @> + +let [] sad () = + raises <@ TypeSafeEnum.tryParse "Wat" @> + raises <@ TypeSafeEnum.toString "Wat" @> + +[)>] +type OutcomeWithOther = Joy | Pain | Misery | Other +and OutcomeWithCatchAllConverter() = + inherit JsonIsomorphism() + override __.Pickle v = + TypeSafeEnum.toString v + + override __.UnPickle json = + json + |> TypeSafeEnum.tryParse + |> Option.defaultValue Other + +let [] fallBackExample () = + test <@ Joy = Serdes.Deserialize "\"Joy\"" @> + test <@ Some Other = Serdes.Deserialize "\"Wat\"" @> + test <@ Other = Serdes.Deserialize "\"Wat\"" @> + raises <@ Serdes.Deserialize "1" @> + test <@ Seq.forall (fun (x,y) -> x = y) <| Seq.zip [Joy; Other] (Serdes.Deserialize "[\"Joy\", \"Wat\"]") @> diff --git a/tests/FsCodec.SystemTextJson.Tests/UmxInteropTests.fs b/tests/FsCodec.SystemTextJson.Tests/UmxInteropTests.fs new file mode 100644 index 0000000..8fdcf8a --- /dev/null +++ b/tests/FsCodec.SystemTextJson.Tests/UmxInteropTests.fs @@ -0,0 +1,36 @@ +/// There's not much to see here - as UMX is a compile-time thing, it should work perfectly with System.Text.Json +module FsCodec.SystemTextJson.Tests.UmxInteropTests + +open FsCodec.SystemTextJson +open FSharp.UMX +open Swensen.Unquote +open System +open System.Text.Json +open Xunit + +// Borrow the converter from the suite that has validated its' core behaviors +type GuidConverter = PicklerTests.GuidConverter + +type [] myGuid +type MyGuid = Guid +type WithEmbeddedMyGuid = + { a: string + + [)>] + b: MyGuid } + +type Configs() as this = + inherit TheoryData() + do this.Add("\"00000000-0000-0000-0000-000000000000\"", Options.Create()) + this.Add("\"00000000000000000000000000000000\"", Options.Create(GuidConverter())) + +let [)>] + ``UMX'd Guid interops with GuidConverter and roundtrips`` + (expectedSer, options : JsonSerializerOptions) = + + let value = Guid.Empty + + let result = Serdes.Serialize(value, options) + test <@ expectedSer = result @> + let des = Serdes.Deserialize(result, options) + test <@ value = des @> diff --git a/tests/FsCodec.Tests/FsCodec.Tests.fsproj b/tests/FsCodec.Tests/FsCodec.Tests.fsproj new file mode 100644 index 0000000..9725e04 --- /dev/null +++ b/tests/FsCodec.Tests/FsCodec.Tests.fsproj @@ -0,0 +1,24 @@ + + + + $(TestTargetFrameworks) + 5 + false + + + + + + + + + + + + + + + + + + diff --git a/tests/FsCodec.NewtonsoftJson.Tests/StreamNameTests.fs b/tests/FsCodec.Tests/StreamNameTests.fs similarity index 100% rename from tests/FsCodec.NewtonsoftJson.Tests/StreamNameTests.fs rename to tests/FsCodec.Tests/StreamNameTests.fs