From 37e943d0c5bccbb09df7fcc35fb31cdfd0713e2b Mon Sep 17 00:00:00 2001 From: fpellet Date: Sat, 27 Jul 2024 17:33:08 +0200 Subject: [PATCH 1/5] Add FsharpFriendlySerializer --- src/Giraffe/Giraffe.fsproj | 1 + src/Giraffe/Json.fs | 19 ++++++- tests/Giraffe.Tests/HttpHandlerTests.fs | 70 +++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/Giraffe/Giraffe.fsproj b/src/Giraffe/Giraffe.fsproj index 0e4c00f2..47b432b1 100644 --- a/src/Giraffe/Giraffe.fsproj +++ b/src/Giraffe/Giraffe.fsproj @@ -66,6 +66,7 @@ + diff --git a/src/Giraffe/Json.fs b/src/Giraffe/Json.fs index b644ccc6..2cab8712 100644 --- a/src/Giraffe/Json.fs +++ b/src/Giraffe/Json.fs @@ -6,6 +6,7 @@ module Json = open System.IO open System.Text.Json open System.Threading.Tasks + open System.Text.Json.Serialization /// /// Interface defining JSON serialization methods. @@ -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() \ No newline at end of file + 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) \ No newline at end of file diff --git a/tests/Giraffe.Tests/HttpHandlerTests.fs b/tests/Giraffe.Tests/HttpHandlerTests.fs index 49f4b3f9..81d6806f 100644 --- a/tests/Giraffe.Tests/HttpHandlerTests.fs +++ b/tests/Giraffe.Tests/HttpHandlerTests.fs @@ -66,6 +66,76 @@ 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 +[] +let ``GET "/json" returns json object with fsharp type`` () = + let ctx = Substitute.For() + ctx.RequestServices + .GetService(typeof) + .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) + } + +[] +let ``GET "/json" returns json object with fsharp type and use custom config`` () = + let ctx = Substitute.For() + let customConfig = + System.Text.Json.Serialization.JsonFSharpOptions.Default() + .WithUnionTagNamingPolicy(System.Text.Json.JsonNamingPolicy.CamelCase) + ctx.RequestServices + .GetService(typeof) + .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) From 11f4cb9646851e51bedee6192a6d24c51a82656f Mon Sep 17 00:00:00 2001 From: fpellet Date: Sat, 27 Jul 2024 17:37:27 +0200 Subject: [PATCH 2/5] Add doc --- DOCUMENTATION.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 03d58170..59ea6e78 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2935,6 +2935,9 @@ 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`. +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: From acc05865772cf2426e6e1bd0800c21653333362e Mon Sep 17 00:00:00 2001 From: fpellet Date: Sat, 27 Jul 2024 17:41:11 +0200 Subject: [PATCH 3/5] Fix NewtonsoftJson doc --- DOCUMENTATION.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 59ea6e78..cbb6ff49 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2971,10 +2971,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()) @@ -3024,7 +3020,8 @@ let configureServices (services : IServiceCollection) = services.AddGiraffe() |> ignore // Now register your custom Json.ISerializer - services.AddSingleton() |> ignore + services.AddSingleton(fun serviceProvider -> + NewtonsoftJson.Serializer(JsonSerializerSettings(), serviceProvider.GetService()) :> Json.ISerializer) |> ignore [] let main _ = From 03f1ce013ba92af25cce95be0ff395020cff8fff Mon Sep 17 00:00:00 2001 From: 64J0 Date: Sun, 28 Jul 2024 19:22:58 -0300 Subject: [PATCH 4/5] Add new test for JsonUnionCaseDummy.JsonUnionCaseDummyB --- tests/Giraffe.Tests/HttpHandlerTests.fs | 33 ++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/Giraffe.Tests/HttpHandlerTests.fs b/tests/Giraffe.Tests/HttpHandlerTests.fs index 81d6806f..6ac78a75 100644 --- a/tests/Giraffe.Tests/HttpHandlerTests.fs +++ b/tests/Giraffe.Tests/HttpHandlerTests.fs @@ -73,8 +73,9 @@ type ResponseWithFsharpType = { and JsonUnionCaseDummy = | JsonUnionCaseDummyA of int | JsonUnionCaseDummyB + [] -let ``GET "/json" returns json object with fsharp type`` () = +let ``GET "/json" returns json object with fsharp type (JsonUnionCaseDummyA)`` () = let ctx = Substitute.For() ctx.RequestServices .GetService(typeof) @@ -102,6 +103,36 @@ let ``GET "/json" returns json object with fsharp type`` () = let content = getBody ctx Assert.Equal(expected, content) } + +[] +let ``GET "/json" returns json object with fsharp type (JsonUnionCaseDummyB)`` () = + let ctx = Substitute.For() + ctx.RequestServices + .GetService(typeof) + .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) + } [] let ``GET "/json" returns json object with fsharp type and use custom config`` () = From 61c1636e3fa78148700cc77f81f35e2899210bab Mon Sep 17 00:00:00 2001 From: 64J0 Date: Sun, 28 Jul 2024 19:23:14 -0300 Subject: [PATCH 5/5] Add NewtonsoftJson sample --- DOCUMENTATION.md | 5 +- Giraffe.sln | 15 +++ samples/NewtonsoftJson/NewtonsoftJson.fsproj | 20 ++++ samples/NewtonsoftJson/Program.fs | 97 ++++++++++++++++++++ 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 samples/NewtonsoftJson/NewtonsoftJson.fsproj create mode 100644 samples/NewtonsoftJson/Program.fs diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index cbb6ff49..ccea84f3 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2935,8 +2935,7 @@ 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`. -This uses `FSharp.SystemTextJson` to customize `System.Text.Json`. +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 @@ -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: diff --git a/Giraffe.sln b/Giraffe.sln index 2f8204d8..0efdaf96 100644 --- a/Giraffe.sln +++ b/Giraffe.sln @@ -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 @@ -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 @@ -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 diff --git a/samples/NewtonsoftJson/NewtonsoftJson.fsproj b/samples/NewtonsoftJson/NewtonsoftJson.fsproj new file mode 100644 index 00000000..e9b1a0c8 --- /dev/null +++ b/samples/NewtonsoftJson/NewtonsoftJson.fsproj @@ -0,0 +1,20 @@ + + + + Exe + net8.0 + + + + + + + + + + + + + + + diff --git a/samples/NewtonsoftJson/Program.fs b/samples/NewtonsoftJson/Program.fs new file mode 100644 index 00000000..151d8466 --- /dev/null +++ b/samples/NewtonsoftJson/Program.fs @@ -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 + +[] +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(fun serviceProvider -> + NewtonsoftJson.Serializer( + JsonSerializerSettings(), + serviceProvider.GetService() + ) + :> Json.ISerializer) + .AddRouting() + .AddResponseCaching() + .AddGiraffe() + |> ignore + +let configureApp (appBuilder: IApplicationBuilder) = + appBuilder + .UseRouting() + .UseResponseCaching() + .UseEndpoints(fun e -> e.MapGiraffeEndpoints(endpoints)) + .UseGiraffe(notFoundHandler) + +[] +let main (args: string array) : int = + let builder = WebApplication.CreateBuilder(args) + configureServices builder.Services + + let app = builder.Build() + + configureApp app + app.Run() + + 0