Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add F# compatible json serializer #609

Merged
merged 5 commits into from
Jul 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions DOCUMENTATION.md
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is also second way - just use the recyclableMemoryStreamManager constant:

    new(settings : JsonSerializerSettings) = Serializer(
        settings,
        Lazy<RecyclableMemoryStreamManager>().Value)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know if this other approach does provide some improvement compared to the one mentioned at this PR? Just curiosity due to this usage of Lazy<'T> and RecyclableMemoryStreamManager.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe not important one, but as Lazy<RecyclableMemoryStreamManager>() was the default, it was one argument less in code. :)

Just started using Giraffe and OpenApi and you guys are doing great work with those 🎉 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, thanks for the feedback 🚀

Original file line number Diff line number Diff line change
Expand Up @@ -2935,6 +2935,8 @@ Please visit the [Giraffe.ViewEngine](https://github.com/giraffe-fsharp/Giraffe.

By default Giraffe uses `System.Text.Json` for (de-)serializing JSON content. An application can modify the default serializer by registering a new dependency which implements the `Json.ISerializer` interface during application startup.

It's possible to use a serializer compatible with Fsharp types: `Json.FsharpFriendlySerializer` instead of `Json.Serializer` (C#-like). This uses `FSharp.SystemTextJson` to customize `System.Text.Json`.

#### Using a different JSON serializer

You can change the entire underlying JSON serializer by creating a new class which implements the `Json.ISerializer` interface:
Expand Down Expand Up @@ -2968,10 +2970,6 @@ For example, one could define a `Newtonsoft.Json` serializer:
let serializer = JsonSerializer.Create settings
let utf8EncodingWithoutBom = UTF8Encoding(false)

new(settings : JsonSerializerSettings) = Serializer(
settings,
recyclableMemoryStreamManager.Value)

static member DefaultSettings =
JsonSerializerSettings(
ContractResolver = CamelCasePropertyNamesContractResolver())
Expand Down Expand Up @@ -3021,7 +3019,8 @@ let configureServices (services : IServiceCollection) =
services.AddGiraffe() |> ignore

// Now register your custom Json.ISerializer
services.AddSingleton<Json.ISerializer, NewtonsoftJson.Serializer>() |> ignore
services.AddSingleton<Json.ISerializer>(fun serviceProvider ->
NewtonsoftJson.Serializer(JsonSerializerSettings(), serviceProvider.GetService<Microsoft.IO.RecyclableMemoryStreamManager>()) :> Json.ISerializer) |> ignore

[<EntryPoint>]
let main _ =
Expand All @@ -3034,6 +3033,8 @@ let main _ =
0
```

Check this [samples/NewtonsoftJson](https://github.com/giraffe-fsharp/Giraffe/tree/master/samples/NewtonsoftJson) project to find this code in a working program.

#### Customizing JsonSerializerSettings

You can change the default `JsonSerializerSettings` of a JSON serializer by registering a new instance of `Json.ISerializer` during application startup. For example, the [`Microsoft.FSharpLu` project](https://github.com/Microsoft/fsharplu/wiki/fsharplu.json) provides a Newtonsoft JSON converter (`CompactUnionJsonConverter`) that serializes and deserializes `Option`s and discriminated unions much more succinctly. If you wanted to use it, and set the culture to German, your configuration would look something like:
Expand Down
15 changes: 15 additions & 0 deletions Giraffe.sln
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_root", "_root", "{FADD8661
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ResponseCachingApp", "samples\ResponseCachingApp\ResponseCachingApp.fsproj", "{FA102AC4-4608-42F9-86C1-1472B416A76E}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "NewtonsoftJson", "samples\NewtonsoftJson\NewtonsoftJson.fsproj", "{A08230F1-DA24-4059-A7F9-4743B36DD3E9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -90,6 +92,18 @@ Global
{FA102AC4-4608-42F9-86C1-1472B416A76E}.Release|x64.Build.0 = Release|Any CPU
{FA102AC4-4608-42F9-86C1-1472B416A76E}.Release|x86.ActiveCfg = Release|Any CPU
{FA102AC4-4608-42F9-86C1-1472B416A76E}.Release|x86.Build.0 = Release|Any CPU
{A08230F1-DA24-4059-A7F9-4743B36DD3E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A08230F1-DA24-4059-A7F9-4743B36DD3E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A08230F1-DA24-4059-A7F9-4743B36DD3E9}.Debug|x64.ActiveCfg = Debug|Any CPU
{A08230F1-DA24-4059-A7F9-4743B36DD3E9}.Debug|x64.Build.0 = Debug|Any CPU
{A08230F1-DA24-4059-A7F9-4743B36DD3E9}.Debug|x86.ActiveCfg = Debug|Any CPU
{A08230F1-DA24-4059-A7F9-4743B36DD3E9}.Debug|x86.Build.0 = Debug|Any CPU
{A08230F1-DA24-4059-A7F9-4743B36DD3E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A08230F1-DA24-4059-A7F9-4743B36DD3E9}.Release|Any CPU.Build.0 = Release|Any CPU
{A08230F1-DA24-4059-A7F9-4743B36DD3E9}.Release|x64.ActiveCfg = Release|Any CPU
{A08230F1-DA24-4059-A7F9-4743B36DD3E9}.Release|x64.Build.0 = Release|Any CPU
{A08230F1-DA24-4059-A7F9-4743B36DD3E9}.Release|x86.ActiveCfg = Release|Any CPU
{A08230F1-DA24-4059-A7F9-4743B36DD3E9}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -103,5 +117,6 @@ Global
{B9B26DDC-608C-42FE-9AB9-6CF0EE4920CD} = {9E6451FB-26E0-4AE4-A469-847F9602E999}
{0E15F922-7A44-4116-9DAB-FAEB94392FEC} = {B9B26DDC-608C-42FE-9AB9-6CF0EE4920CD}
{FA102AC4-4608-42F9-86C1-1472B416A76E} = {9E6451FB-26E0-4AE4-A469-847F9602E999}
{A08230F1-DA24-4059-A7F9-4743B36DD3E9} = {9E6451FB-26E0-4AE4-A469-847F9602E999}
EndGlobalSection
EndGlobal
20 changes: 20 additions & 0 deletions samples/NewtonsoftJson/NewtonsoftJson.fsproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<Compile Include="Program.fs" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="../../src/Giraffe/Giraffe.fsproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>

</Project>
97 changes: 97 additions & 0 deletions samples/NewtonsoftJson/Program.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Hosting
open Giraffe
open Giraffe.EndpointRouting
open Newtonsoft.Json

[<RequireQualifiedAccess>]
module NewtonsoftJson =
open System.IO
open System.Text
open System.Threading.Tasks
open Microsoft.IO
open Newtonsoft.Json.Serialization

type Serializer(settings: JsonSerializerSettings, rmsManager: RecyclableMemoryStreamManager) =
let serializer = JsonSerializer.Create settings
let utf8EncodingWithoutBom = UTF8Encoding(false)

static member DefaultSettings =
JsonSerializerSettings(ContractResolver = CamelCasePropertyNamesContractResolver())

interface Json.ISerializer with
member __.SerializeToString(x: 'T) =
JsonConvert.SerializeObject(x, settings)

member __.SerializeToBytes(x: 'T) =
JsonConvert.SerializeObject(x, settings) |> Encoding.UTF8.GetBytes

member __.SerializeToStreamAsync (x: 'T) (stream: Stream) =
task {
use memoryStream = rmsManager.GetStream("giraffe-json-serialize-to-stream")
use streamWriter = new StreamWriter(memoryStream, utf8EncodingWithoutBom)
use jsonTextWriter = new JsonTextWriter(streamWriter)
serializer.Serialize(jsonTextWriter, x)
jsonTextWriter.Flush()
memoryStream.Seek(0L, SeekOrigin.Begin) |> ignore
do! memoryStream.CopyToAsync(stream, 65536)
}
:> Task

member __.Deserialize<'T>(json: string) =
JsonConvert.DeserializeObject<'T>(json, settings)

member __.Deserialize<'T>(bytes: byte array) =
let json = Encoding.UTF8.GetString bytes
JsonConvert.DeserializeObject<'T>(json, settings)

member __.DeserializeAsync<'T>(stream: Stream) =
task {
use memoryStream = rmsManager.GetStream("giraffe-json-deserialize")
do! stream.CopyToAsync(memoryStream)
memoryStream.Seek(0L, SeekOrigin.Begin) |> ignore
use streamReader = new StreamReader(memoryStream)
use jsonTextReader = new JsonTextReader(streamReader)
return serializer.Deserialize<'T>(jsonTextReader)
}

type JsonResponse = { Foo: string; Bar: string; Age: int }

let endpoints: Endpoint list =
[ GET [ route "/json" (json { Foo = "john"; Bar = "doe"; Age = 30 }) ] ]

let notFoundHandler = "Not Found" |> text |> RequestErrors.notFound

let configureServices (services: IServiceCollection) =
services
.AddSingleton<Json.ISerializer>(fun serviceProvider ->
NewtonsoftJson.Serializer(
JsonSerializerSettings(),
serviceProvider.GetService<Microsoft.IO.RecyclableMemoryStreamManager>()
)
:> Json.ISerializer)
.AddRouting()
.AddResponseCaching()
.AddGiraffe()
|> ignore

let configureApp (appBuilder: IApplicationBuilder) =
appBuilder
.UseRouting()
.UseResponseCaching()
.UseEndpoints(fun e -> e.MapGiraffeEndpoints(endpoints))
.UseGiraffe(notFoundHandler)

[<EntryPoint>]
let main (args: string array) : int =
let builder = WebApplication.CreateBuilder(args)
configureServices builder.Services

let app = builder.Build()

configureApp app
app.Run()

0
1 change: 1 addition & 0 deletions src/Giraffe/Giraffe.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
<PackageReference Include="FSharp.Core" Version="6.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.*" />
<PackageReference Include="System.Text.Json" Version="8.0.*" />
<PackageReference Include="FSharp.SystemTextJson" Version="1.3.*" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.*" PrivateAssets="All" />
<PackageReference Include="Giraffe.ViewEngine" Version="1.4.*" />
</ItemGroup>
Expand Down
19 changes: 18 additions & 1 deletion src/Giraffe/Json.fs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module Json =
open System.IO
open System.Text.Json
open System.Threading.Tasks
open System.Text.Json.Serialization

/// <summary>
/// Interface defining JSON serialization methods.
Expand Down Expand Up @@ -53,4 +54,20 @@ module Json =
JsonSerializer.Deserialize<'T>(Span<_>.op_Implicit(bytes.AsSpan()), options)

member __.DeserializeAsync<'T> (stream : Stream) : Task<'T> =
JsonSerializer.DeserializeAsync<'T>(stream, options).AsTask()
JsonSerializer.DeserializeAsync<'T>(stream, options).AsTask()

module FsharpFriendlySerializer =
let DefaultOptions =
JsonFSharpOptions.Default()

let private appendJsonFSharpOptions (fsharpOptions: JsonFSharpOptions) (jsonOptions: JsonSerializerOptions) =
jsonOptions.Converters.Add(JsonFSharpConverter(fsharpOptions))
jsonOptions

let buildConfig (fsharpOptions: JsonFSharpOptions option) (jsonOptions: JsonSerializerOptions option) =
jsonOptions
|> Option.defaultValue (JsonSerializerOptions())
|> appendJsonFSharpOptions (fsharpOptions |> Option.defaultValue DefaultOptions)

type FsharpFriendlySerializer (?fsharpOptions: JsonFSharpOptions, ?jsonOptions: JsonSerializerOptions) =
inherit Serializer(FsharpFriendlySerializer.buildConfig fsharpOptions jsonOptions)
101 changes: 101 additions & 0 deletions tests/Giraffe.Tests/HttpHandlerTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,107 @@ let ``GET "/json" returns json object`` () =
| Some ctx -> Assert.Equal(expected, getBody ctx)
}

type ResponseWithFsharpType = {
ValueA: string option
ValueB: JsonUnionCaseDummy
}
and JsonUnionCaseDummy =
| JsonUnionCaseDummyA of int
| JsonUnionCaseDummyB

[<Fact>]
let ``GET "/json" returns json object with fsharp type (JsonUnionCaseDummyA)`` () =
let ctx = Substitute.For<HttpContext>()
ctx.RequestServices
.GetService(typeof<Json.ISerializer>)
.Returns(Json.FsharpFriendlySerializer())
|> ignore

let app =
GET >=> choose [
route "/" >=> text "Hello World"
route "/foo" >=> text "bar"
route "/json" >=> json { ValueA = Some "hello"; ValueB = JsonUnionCaseDummyA 42 }
setStatusCode 404 >=> text "Not found" ]

ctx.Request.Method.ReturnsForAnyArgs "GET" |> ignore
ctx.Request.Path.ReturnsForAnyArgs (PathString("/json")) |> ignore
ctx.Response.Body <- new MemoryStream()
let expected = """{"ValueA":"hello","ValueB":{"Case":"JsonUnionCaseDummyA","Fields":[42]}}"""

task {
let! result = app next ctx

match result with
| None -> assertFailf "Result was expected to be %s" expected
| Some ctx ->
let content = getBody ctx
Assert.Equal(expected, content)
}

[<Fact>]
let ``GET "/json" returns json object with fsharp type (JsonUnionCaseDummyB)`` () =
let ctx = Substitute.For<HttpContext>()
ctx.RequestServices
.GetService(typeof<Json.ISerializer>)
.Returns(Json.FsharpFriendlySerializer())
|> ignore

let app =
GET >=> choose [
route "/" >=> text "Hello World"
route "/foo" >=> text "bar"
route "/json" >=> json { ValueA = Some "hello"; ValueB = JsonUnionCaseDummyB }
setStatusCode 404 >=> text "Not found" ]

ctx.Request.Method.ReturnsForAnyArgs "GET" |> ignore
ctx.Request.Path.ReturnsForAnyArgs (PathString("/json")) |> ignore
ctx.Response.Body <- new MemoryStream()
let expected = """{"ValueA":"hello","ValueB":{"Case":"JsonUnionCaseDummyB"}}"""

task {
let! result = app next ctx

match result with
| None -> assertFailf "Result was expected to be %s" expected
| Some ctx ->
let content = getBody ctx
Assert.Equal(expected, content)
}

[<Fact>]
let ``GET "/json" returns json object with fsharp type and use custom config`` () =
let ctx = Substitute.For<HttpContext>()
let customConfig =
System.Text.Json.Serialization.JsonFSharpOptions.Default()
.WithUnionTagNamingPolicy(System.Text.Json.JsonNamingPolicy.CamelCase)
ctx.RequestServices
.GetService(typeof<Json.ISerializer>)
.Returns(Json.FsharpFriendlySerializer(customConfig, Json.Serializer.DefaultOptions))
|> ignore

let app =
GET >=> choose [
route "/" >=> text "Hello World"
route "/foo" >=> text "bar"
route "/json" >=> json { ValueA = Some "hello"; ValueB = JsonUnionCaseDummyA 42 }
setStatusCode 404 >=> text "Not found" ]

ctx.Request.Method.ReturnsForAnyArgs "GET" |> ignore
ctx.Request.Path.ReturnsForAnyArgs (PathString("/json")) |> ignore
ctx.Response.Body <- new MemoryStream()
let expected = """{"valueA":"hello","valueB":{"Case":"jsonUnionCaseDummyA","Fields":[42]}}"""

task {
let! result = app next ctx

match result with
| None -> assertFailf "Result was expected to be %s" expected
| Some ctx ->
let content = getBody ctx
Assert.Equal(expected, content)
}

let DefaultMocksWithSize =
[
let ``powers of two`` = [ 1..10 ] |> List.map (pown 2)
Expand Down
Loading