diff --git a/.psscripts/build-functions.ps1 b/.psscripts/build-functions.ps1 index 59e2dfed..a7962989 100644 --- a/.psscripts/build-functions.ps1 +++ b/.psscripts/build-functions.ps1 @@ -15,6 +15,27 @@ function Test-IsWindows [environment]::OSVersion.Platform -ne "Unix" } +function Invoke-UnsafeCmd ($cmd) +{ + <# + .DESCRIPTION + Runs a shell or bash command, but doesn't throw an error if the command didn't exit with 0. + + .PARAMETER cmd + The command to be executed. + + .EXAMPLE + Invoke-Cmd -Cmd "dotnet new classlib" + + .NOTES + Use this PowerShell command to execute any CLI commands which might not exit with 0 on a success. + #> + + Write-Host $cmd -ForegroundColor DarkCyan + if (Test-IsWindows) { $cmd = "cmd.exe /C $cmd" } + Invoke-Expression -Command $cmd +} + function Invoke-Cmd ($Cmd) { <# @@ -31,9 +52,7 @@ function Invoke-Cmd ($Cmd) Use this PowerShell command to execute any dotnet CLI commands in order to ensure that they behave the same way in the case of an error across different environments (Windows, OSX and Linux). #> - Write-Host $Cmd -ForegroundColor DarkCyan - if (Test-IsWindows) { $Cmd = "cmd.exe /C $Cmd" } - Invoke-Expression -Command $Cmd + Invoke-UnsafeCmd $cmd if ($LastExitCode -ne 0) { Write-Error "An error occured when executing '$Cmd'."; return } } @@ -52,13 +71,39 @@ function Remove-OldBuildArtifacts Remove-Item $_ -Recurse -Force } } -function Test-CompareVersions ($projFile, [string]$gitTag) +function Get-ProjectVersion ($projFile) { - Write-Host "Matching version against git tag..." -ForegroundColor Magenta + <# + .DESCRIPTION + Gets the value of a .NET Core *.csproj, *.fsproj or *.vbproj file. + + .PARAMETER cmd + The relative or absolute path to the .NET Core project file. + #> [xml]$xml = Get-Content $projFile - [string]$version = $xml.Project.PropertyGroup.Version + [string] $version = $xml.Project.PropertyGroup.Version + $version +} + +function Get-NuspecVersion ($nuspecFile) +{ + <# + .DESCRIPTION + Gets the value of a .nuspec file. + + .PARAMETER cmd + The relative or absolute path to the .nuspec file. + #> + [xml] $xml = Get-Content $nuspecFile + [string] $version = $xml.package.metadata.version + $version +} + +function Test-CompareVersions ($version, [string]$gitTag) +{ + Write-Host "Matching version against git tag..." -ForegroundColor Magenta Write-Host "Project version: $version" -ForegroundColor Cyan Write-Host "Git tag version: $gitTag" -ForegroundColor Cyan @@ -74,6 +119,7 @@ function Test-CompareVersions ($projFile, [string]$gitTag) function dotnet-info { Invoke-Cmd "dotnet --info" } function dotnet-version { Invoke-Cmd "dotnet --version" } +function dotnet-restore ($project, $argv) { Invoke-Cmd "dotnet restore $project $argv" } function dotnet-build ($project, $argv) { Invoke-Cmd "dotnet build $project $argv" } function dotnet-run ($project, $argv) { Invoke-Cmd "dotnet run --project $project $argv" } function dotnet-pack ($project, $argv) { Invoke-Cmd "dotnet pack $project $argv" } @@ -167,6 +213,65 @@ function Write-DotnetCoreVersions Write-Host ".NET Core Runtime version: $runtimeVersion" -ForegroundColor Cyan } +function Get-DesiredSdk +{ + <# + .DESCRIPTION + Gets the desired .NET Core SDK version from the global.json file. + #> + + Get-Content "global.json" ` + | ConvertFrom-Json ` + | ForEach-Object { $_.sdk.version.ToString() } +} + +function Get-NetCoreSdkFromWeb ($version) +{ + <# + .DESCRIPTION + Downloads the desired .NET Core SDK version from the internet and saves it under a temporary file name which will be returned by the function. + + .PARAMETER version + The SDK version which should be downloaded. + #> + + $os = if (Test-IsWindows) { "windows" } else { "linux" } + + $response = Invoke-WebRequest ` + -Uri "https://www.microsoft.com/net/download/thank-you/dotnet-sdk-$version-$os-x64-binaries" ` + -Method Get ` + -MaximumRedirection 0 ` + + $downloadLink = + $response.Links ` + | Where-Object { $_.onclick -eq "recordManualDownload()" } ` + | Select-Object -Expand href + + $tempFile = [System.IO.Path]::GetTempFileName() + $webClient = New-Object System.Net.WebClient + $webClient.DownloadFile($downloadLink, $tempFile) + return $tempFile +} + +function Install-NetCoreSdk ($sdkZipPath) +{ + <# + .DESCRIPTION + Extracts the zip archive which contains the .NET Core SDK and installs it in the current working directory under .dotnetsdk. + + .PARAMETER version + The zip archive which contains the .NET Core SDK. + #> + + + $env:DOTNET_INSTALL_DIR = "$pwd\.dotnetsdk" + New-Item $env:DOTNET_INSTALL_DIR -ItemType Directory -Force + + Add-Type -AssemblyName System.IO.Compression.FileSystem; + [System.IO.Compression.ZipFile]::ExtractToDirectory($sdkZipPath, $env:DOTNET_INSTALL_DIR) + $env:Path = "$env:DOTNET_INSTALL_DIR;$env:Path" +} + # ---------------------------------------------- # AppVeyor functions # ---------------------------------------------- @@ -175,16 +280,43 @@ function Test-IsAppVeyorBuild { return ($env:APPVEYOR -eq $true function Test-IsAppVeyorBuildTriggeredByGitTag { return ($env:APPVEYOR_REPO_TAG -eq $true) } function Get-AppVeyorGitTag { return $env:APPVEYOR_REPO_TAG_NAME } -function Update-AppVeyorBuildVersion ($projFile) +function Update-AppVeyorBuildVersion ($version) { if (Test-IsAppVeyorBuild) { Write-Host "Updating AppVeyor build version..." -ForegroundColor Magenta - - [xml]$xml = Get-Content $projFile - $version = $xml.Project.PropertyGroup.Version $buildVersion = "$version-$env:APPVEYOR_BUILD_NUMBER" Write-Host "Setting AppVeyor build version to $buildVersion." Update-AppveyorBuild -Version $buildVersion } +} + +# ---------------------------------------------- +# Host Writing functions +# ---------------------------------------------- + +function Write-BuildHeader ($projectTitle) +{ + $header = " $projectTitle "; + $bar = "" + for ($i = 0; $i -lt $header.Length; $i++) { $bar += "-" } + + Write-Host "" + Write-Host $bar -ForegroundColor DarkYellow + Write-Host $header -ForegroundColor DarkYellow + Write-Host $bar -ForegroundColor DarkYellow + Write-Host "" +} + +function Write-SuccessFooter ($msg) +{ + $footer = " $msg "; + $bar = "" + for ($i = 0; $i -lt $footer.Length; $i++) { $bar += "-" } + + Write-Host "" + Write-Host $bar -ForegroundColor Green + Write-Host $footer -ForegroundColor Green + Write-Host $bar -ForegroundColor Green + Write-Host "" } \ No newline at end of file diff --git a/.psscripts/install-dotnet.ps1 b/.psscripts/install-dotnet.ps1 index 7f3770e2..1c71f646 100644 --- a/.psscripts/install-dotnet.ps1 +++ b/.psscripts/install-dotnet.ps1 @@ -1,67 +1,15 @@ -# ---------------------------------------------------------- -# Install script to check and download the correct .NET SDK -# ---------------------------------------------------------- - -function Test-IsWindows -{ - [environment]::OSVersion.Platform -ne "Unix" -} - -function Invoke-Cmd ($cmd) -{ - Write-Host $cmd -ForegroundColor DarkCyan - if (Test-IsWindows) { $cmd = "cmd.exe /C $cmd" } - Invoke-Expression -Command $cmd - if ($LastExitCode -ne 0) { Write-Error "An error occured when executing '$cmd'."; return } -} - -function dotnet-version { Invoke-Cmd "dotnet --version" } - -function Get-DesiredSdk -{ - Get-Content "global.json" | ConvertFrom-Json | % { $_.sdk.version.ToString() } -} - -function Get-NetCoreSdk ($version) -{ - $os = if (Test-IsWindows) { "windows" } else { "linux" } - - $response = Invoke-WebRequest ` - -Uri "https://www.microsoft.com/net/download/thank-you/dotnet-sdk-$version-$os-x64-binaries" ` - -Method Get ` - -MaximumRedirection 0 ` - - $downloadLink = - $response.Links ` - | Where-Object { $_.onclick -eq "recordManualDownload()" } ` - | Select-Object -Expand href - - $tempFile = [System.IO.Path]::GetTempFileName() - $webClient = New-Object System.Net.WebClient - $webClient.DownloadFile($downloadLink, $tempFile) - return $tempFile -} - -function Install-NetCoreSdk ($sdkZipPath) -{ - $env:DOTNET_INSTALL_DIR = "$pwd\.dotnetsdk" - New-Item $env:DOTNET_INSTALL_DIR -ItemType Directory -Force - - Add-Type -AssemblyName System.IO.Compression.FileSystem; - [System.IO.Compression.ZipFile]::ExtractToDirectory($sdkZipPath, $env:DOTNET_INSTALL_DIR) - $env:Path = "$env:DOTNET_INSTALL_DIR;$env:Path" -} - # ---------------------------------------------- # Install .NET Core SDK # ---------------------------------------------- $ErrorActionPreference = "Stop" +Import-module "$PSScriptRoot\build-functions.ps1" -Force + # Rename the global.json before making the dotnet --version call # This will prevent AppVeyor to fail because it might not find # the desired SDK specified in the global.json -$globalJson = Get-Item "global.json" +$globalJson = Get-Item "$PSScriptRoot\..\global.json" Rename-Item -Path $globalJson.FullName -NewName "global.json.bak" -Force # Get the current .NET Core SDK version @@ -81,7 +29,7 @@ if ($desiredSdk -eq $currentSdk) Write-Host "The current .NET SDK ($currentSdk) doesn't match the project's desired .NET SDK ($desiredSdk)." -ForegroundColor Yellow Write-Host "Attempting to download and install the correct .NET SDK..." -$sdkZipPath = Get-NetCoreSdk $desiredSdk +$sdkZipPath = Get-NetCoreSdkFromWeb $desiredSdk Install-NetCoreSdk $sdkZipPath Write-Host ".NET SDK installation complete." -ForegroundColor Green diff --git a/.psscripts/nuget.ps1 b/.psscripts/nuget-updates.ps1 similarity index 96% rename from .psscripts/nuget.ps1 rename to .psscripts/nuget-updates.ps1 index 9a276218..f49c327b 100644 --- a/.psscripts/nuget.ps1 +++ b/.psscripts/nuget-updates.ps1 @@ -36,7 +36,7 @@ Write-Host " Scanning all projects for NuGet package upgrades " Write-Host "--------------------------------------------------" Write-Host "" -$projects = Get-ChildItem "*.*proj" -Recurse | % { $_.FullName } +$projects = Get-ChildItem "..\**\*.*proj" -Recurse | % { $_.FullName } foreach ($project in $projects) { diff --git a/.travis.yml b/.travis.yml index 852d84b7..01ab1e06 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,8 @@ language: csharp sudo: required dist: trusty -dotnet: 2.1.400 +dotnet: 2.1.402 + mono: - 4.6.1 - 4.8.1 diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index f82e436e..43f3cefd 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -33,6 +33,8 @@ An in depth functional reference to all of Giraffe's default features. - [Content Negotiation](#content-negotiation) - [Streaming](#streaming) - [Redirection](#redirection) + - [Response Caching](#response-caching) + - [Response Compression](#response-compression) - [Giraffe View Engine](#giraffe-view-engine) - [HTML Elements and Attributes](#html-elements-and-attributes) - [Text Content](#text-content) @@ -45,6 +47,7 @@ An in depth functional reference to all of Giraffe's default features. - [JSON](#json) - [XML](#xml) - [Miscellaneous](#miscellaneous) + - [Short GUIDs and Short IDs](#short-guids-and-short-ids) - [Common Helper Functions](#common-helper-functions) - [Computation Expressions](#computation-expressions) - [Additional Features](#additional-features) @@ -153,9 +156,10 @@ let webApp = Another important aspect of Giraffe is that it natively works with .NET's `Task` and `Task<'T>` objects instead of relying on F#'s `async {}` workflows. The main benefit of this is that it removes the necessity of converting back and forth between tasks and async workflows when building a Giraffe web application (because ASP.NET Core only works with tasks out of the box). -For this purpose Giraffe uses the `task {}` computation expression which comes with the [`TaskBuilder.fs` NuGet package](https://www.nuget.org/packages/TaskBuilder.fs/). Syntactically it works identical to F#'s async workflows: +For this purpose Giraffe uses the `task {}` computation expression which comes with the [`TaskBuilder.fs` NuGet package](https://www.nuget.org/packages/TaskBuilder.fs/). Syntactically it works identical to F#'s async workflows (after opening the `FSharp.Control.Tasks.V2.ContextInsensitive` module): ```fsharp +open FSharp.Control.Tasks.V2.ContextInsensitive open Giraffe let personHandler = @@ -166,7 +170,7 @@ let personHandler = } ``` -The `task {}` CE is an independent project maintained by [Robert Peele](https://github.com/rspeele) and can be used from any other F# application as well. All you have to do is add a reference to the `TaskBuilder.fs` NuGet library and open the module: +The `task {}` CE is an independent project maintained by [Robert Peele](https://github.com/rspeele) and can be used from any other F# application as well. All you have to do is add a reference to the `TaskBuilder.fs` NuGet library and open the `FSharp.Control.Tasks.V2` module: ```fsharp open FSharp.Control.Tasks.V2 @@ -895,7 +899,24 @@ The format string supports the following format chars: | `%i` | `int` | | `%d` | `int64` | | `%f` | `float`/`double` | -| `%O` | `Guid` | +| `%O` | `Guid` (including short GUIDs*) | +| `%u` | `uint64` (formatted as a short ID*) | + +*) Please note that the `%O` and `%u` format characters also support URL friendly short GUIDs and IDs. + +The `%O` format character supports GUIDs in the format of: + +- `00000000000000000000000000000000` +- `00000000-0000-0000-0000-000000000000` +- `Xy0MVKupFES9NpmZ9TiHcw` + +The last string represents an example of a [Short GUID](https://madskristensen.net/blog/A-shorter-and-URL-friendly-GUID) which is a normal GUID shortened into a URL encoded 22 character long string. Routes which use the `%O` format character will be able to automatically resolve a [Short GUID](https://madskristensen.net/blog/A-shorter-and-URL-friendly-GUID) as well as a normal GUID into a `System.Guid` argument. + +The `%u` format character can only resolve an 11 character long [Short ID](https://webapps.stackexchange.com/questions/54443/format-for-id-of-youtube-video) (aka YouTube ID) into a `uint64` value. + +Short GUIDs and short IDs are popular choices to make URLs shorter and friendlier whilst still mapping to a unique `System.Guid` or `uint64` value on the server side. + +[Short GUIDs and IDs can also be resolved from query string parameters](#short-guids-and-short-ids) by making use of the `ShortGuid` and `ShortId` helper modules. #### routeCif @@ -911,6 +932,8 @@ let webApp = ] ``` +Please be aware that a case insensitive URL matching will return unexpected results in combination with case sensitive arguments such as short GUIDs and short IDs. + #### routeBind If you need to bind route parameters directly to a type then you can use the `routeBind<'T>` http handler. Unlike `routef` or `routeCif` which work with a format string the `routeBind<'T>` http handler tries to match named parameters to the properties of a given type `'T`: @@ -1318,7 +1341,7 @@ let webApp = ] ``` -Alternatively you can use the `bindFrom<'T>` http handler (which also accepts an additional parameter of type `CultureInfo option`): +Alternatively you can use the `bindForm<'T>` http handler (which also accepts an additional parameter of type `CultureInfo option`): ```fsharp [] @@ -2186,25 +2209,42 @@ let someHandler (str : string) : HttpHandler = #### Writing JSON -The `WriteJsonAsync (dataObj : obj)` extension method and the `json (dataObj : obj)` http handler will both serialize an object to a JSON string and write the output to the response stream of the HTTP request. They will also set the `Content-Length`HTTP header and the `Content-Type` header to `application/json` in the response: +The `WriteJsonAsync<'T> (dataObj : 'T)` extension method and the `json<'T> (dataObj : 'T)` http handler will both serialize an object to a JSON string and write the output to the response stream of the HTTP request. They will also set the `Content-Length` HTTP header and the `Content-Type` header to `application/json` in the response: ```fsharp -let someHandler (dataObj : obj) : HttpHandler = +let someHandler (animal : Animal) : HttpHandler = fun (next : HttpFunc) (ctx : HttpContext) -> task { // Do stuff - return! ctx.WriteJsonAsync dataObj + return! ctx.WriteJsonAsync animal } // or... -let someHandler (dataObj : obj) : HttpHandler = +let someHandler (animal : Animal) : HttpHandler = // Do stuff - json dataObj + json animal ``` The underlying JSON serializer can be configured as a dependency during application startup (see [JSON](#json)). +The `WriteJsonChunkedAsync<'T> (dataObj : 'T)` extension method and the `jsonChunked (dataObj : 'T)` http handler write directly to th response stream of the HTTP request without extra buffering into a byte array. They will not set a `Content-Length` header and instead set the `Transfer-Encoding: chunked` header and `Content-Type: application/json`: + +```fsharp +let someHandler (person : Person) : HttpHandler = + fun (next : HttpFunc) (ctx : HttpContext) -> + task { + // Do stuff + return! ctx.WriteJsonChunkedAsync person + } + +// or... + +let someHandler (person : Person) : HttpHandler = + // Do stuff + jsonChunked person +``` + #### Writing XML The `WriteXmlAsync (dataObj : obj)` extension method and the `xml (dataObj : obj)` http handler will both serialize an object to an XML string and write the output to the response stream of the HTTP request. They will also set the `Content-Length`HTTP header and the `Content-Type` header to `application/xml` in the response: @@ -2529,11 +2569,113 @@ let webApp = Please note that if the `permanent` flag is set to `true` then the Giraffe web application will send a `301` HTTP status code to browsers which will tell them that the redirection is permanent. This often leads to browsers cache the information and not hit the deprecated URL a second time any more. If this is not desired then please set `permanent` to `false` in order to guarantee that browsers will continue hitting the old URL before redirecting to the (temporary) new one. +### Response Caching + +ASP.NET Core comes with a standard [Response Caching Middleware](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/middleware?view=aspnetcore-2.1) which works out of the box with Giraffe. + +If you are not already using one of the two ASP.NET Core meta packages (`Microsoft.AspNetCore.App` or `Microsoft.AspNetCore.All`) then you will have to add an additional reference to the [Microsoft.AspNetCore.ResponseCaching](https://www.nuget.org/packages/Microsoft.AspNetCore.ResponseCaching/) NuGet package. + +After adding the NuGet package you need to register the response caching middleware inside your application's startup code before registering Giraffe: + +```fsharp +let configureServices (services : IServiceCollection) = + services + .AddResponseCaching() // <-- Here the order doesn't matter + .AddGiraffe() // This is just registering dependencies + |> ignore + +let configureApp (app : IApplicationBuilder) = + app.UseGiraffeErrorHandler(errorHandler) + .UseStaticFiles() // Optional if you use static files + .UseAuthentication() // Optional if you use authentication + .UseResponseCaching() // <-- Before UseGiraffe webApp + .UseGiraffe webApp +``` + +After setting up the [ASP.NET Core response caching middleware](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/middleware?view=aspnetcore-2.1#configuration) you can use Giraffe's response caching http handlers to add response caching to your routes: + +```fsharp +// A test handler which generates a new GUID on every request +let generateGuidHandler : HttpHandler = + warbler (fun _ -> text (Guid.NewGuid().ToString())) + +let webApp = + GET >=> choose [ + route "/route1" >=> publicResponseCaching 30 None >=> generateGuidHandler + route "/route2" >=> noResponseCaching >=> generateGuidHandler + ] +``` + +Requests to `/route1` can be cached for up to 30 seconds whilst requests to `/route2` have response caching completely disabled. + +*Note: if you test the above code with [Postman](https://www.getpostman.com/) then make sure you [disable the No-Cache feature](https://www.getpostman.com/docs/v6/postman/launching_postman/settings) in Postman in order to test the correct caching behaviour.* + +Giraffe offers in total 4 http handlers which can be used to configure response caching for an endpoint. + +In the above example we used the `noResponseCaching` http handler to completely disable response caching on the client and on any proxy server. The `noResponseCaching` http handler will send the following HTTP headers in the response: + +``` +Cache-Control: no-store, no-cache +Pragma: no-cache +Expires: -1 +``` + +The `publicResponseCaching` or `privateResponseCaching` http handlers will enable response caching on the client and/or on proxy servers. The +`publicResponseCaching` http handler will set the `Cache-Control` directive to `public`, which means that not only the client is allowed to cache a response for the given cache duration, but also any intermediary proxy server as well as the ASP.NET Core middleware. This is useful for HTTP GET/HEAD endpoints which do not hold any user specific data, authentication data or any cookies and where the response data doesn't change frequently. + +The `privateResponseCaching` http handler sets the `Cache-Control` directive to `private` which means that only the end client is allowed to store the response for the given cache duration. Proxy servers and the ASP.NET Core response caching middleware must not cache the response. + +Both http handlers require the cache duration in seconds and an optional `vary` parameter: + +```fsharp +// Cache for 10 seconds without any vary headers +publicResponseCaching 10 None + +// Cache for 30 seconds with Accept and Accept-Encoding as vary headers +publicResponseCaching 30 (Some "Accept, Accept-Encoding") +``` + +The `vary` parameter specifies which HTTP request headers must be respected to vary the cached response. For example if an endpoint returns a different response (`Content-Type`) based on the client's `Accept` header (= [content negotiation](#content-negotiation)) then the `Accept` header must also be considered when returning a response from the cache. The same applies if the web server has response compression enabled. If a response varies based on the client's accepted compression algorithm then the cache must also respect the client's `Accept-Encoding` HTTP header when serving a response from the cache. + +#### VaryByQueryKeys + +The ASP.NET Core response caching middleware offers one more additional feature which is not part of the response's HTTP headers. By default, if a route is cachable then the middleware will try to returnn a cached response even if the query parameters were different. + +For example if a request to `/foo/bar` has been cached, then the cached version will also be returned if a request is made to `/foo/bar?query1=a` or `/foo/bar?query1=a&query2=b`. + +Sometimes this is not desired and the `VaryByQueryKeys` feature lets the [middleware vary its cached responses based on a request's query keys](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/middleware?view=aspnetcore-2.1#varybyquerykeys). + +The generic `responseCaching` http handler is the most basic response caching handler which can be used to configure custom response caching handlers as well as make use of the `VaryByQueryKeys` feature: + +```fsharp +responseCaching + (Public (TimeSpan.FromSeconds (float 5))) + (Some "Accept, Accept-Encoding") + (Some [| "query1"; "query2" |]) +``` + +The first parameter is of type `CacheDirective` which is defines as following: + +```fsharp +type CacheDirective = + | NoCache + | Public of TimeSpan + | Private of TimeSpan +``` + +The second parameter is an `string option` which defines the `vary` parameter. + +The third and last parameter is a `string list option` which defines an optional list of query parameter values which must be used to vary a cached response by the ASP.NET Core response caching middleware. Please be aware that this feature only applies to the ASP.NET Core response caching middleware and will not be respected by any intermediate proxy servers. + +### Response Compression + +ASP.NET Core has its own [Response Compression Middleware](https://docs.microsoft.com/en-us/aspnet/core/performance/response-compression?view=aspnetcore-2.1&tabs=aspnetcore2x) which works out of the box with Giraffe. There's no additional functionality or http handlers required in order to make it work with Giraffe web applications. + ## Giraffe View Engine -Giraffe has its own functional view engine which can be used to build rich GUIs for web applications. The single biggest and best contrast to other view engines (e.g. Razor, Liquid, etc.) is that the Giraffe View Engine is entirely functional written in normal (and compiled) F# code. +Giraffe has its own functional view engine which can be used to build rich UIs for web applications. The single biggest and best contrast to other view engines (e.g. Razor, Liquid, etc.) is that the Giraffe View Engine is entirely functional written in normal (and compiled) F# code. -This means that the Giraffe View Engine is by definition one of the most feature rich view engines, requires no disk IO to load a view and views are automatically compiled at build time. +This means that the Giraffe View Engine is by definition one of the most feature rich view engines available, requires no disk IO to load a view and views are automatically compiled at build time. The Giraffe View Engine uses traditional functions and F# record types to generate rich HTML/XML views. @@ -2556,7 +2698,7 @@ let indexView = ] ``` -A HTML element can either be a `ParentNode`, a `VoidElement` or `RawText`/`EncodedText`. +A HTML element can either be a `ParentNode`, a `VoidElement` or a `Text` element. For example the `` or `
` tags are typical `ParentNode` elements. They can hold an `XmlAttribute list` and a second `XmlElement list` for their child elements: @@ -2614,7 +2756,7 @@ Naturally the most frequent content in any HTML document is pure text:
``` -The Giraffe View Engine lets one create pure text content as either `RawText` or `EncodedText`: +The Giraffe View Engine lets one create pure text content as a `Text` element. A `Text` element can either be generated via the `rawText` or `encodedText` functions: ```fsharp let someHtml = @@ -2624,7 +2766,7 @@ let someHtml = ] ``` -The `rawText` function will create an object of type `RawText` and the `encodedText` function will output an object of type `EncodedText`. The difference is that the latter will HTML encode the value when rendering the view. +The `rawText` function will create an object of type `Text` where the content will be rendered in its original form and the `encodedText` function will output a string where the content has been HTML encoded. In this example the first `p` element will literally output the string as it is (`
Hello World
`) while the second `p` element will output the value as HTML encoded string `<div>Hello World</div>`. @@ -2736,6 +2878,44 @@ let output1 = renderHtmlNode someContent let output2 = renderXmlNode someContent ``` +Additionally with Giraffe 3.0.0 or higher there is a new module called `ViewBuilder` under the `Giraffe.GiraffeViewEngine` namespace. This module exposes additional view rendering functions which compile a view into a `StringBuilder` object instead of returning a single `string`: + +- `ViewBuilder.buildHtmlDocument` +- `ViewBuilder.buildHtmlNodes` +- `ViewBuilder.buildHtmlNode` +- `ViewBuilder.buildXmlNodes` +- `ViewBuilder.buildXmlNode` + +The `ViewBuilder.build[...]` functions can be useful if there is additional string processing required before/after composing a view by the `GiraffeViewEngine` (e.g. embedding HTML snippets in an email template, etc.). These functions also serve as the lower level building blocks of the equivalent `render[...]` functions. + +Example usage: + +```fsharp +open System.Text +open Giraffe.GiraffeViewEngine + +let someHtml = + div [] [ + tag "foo" [ attr "bar" "blah" ] [ + voidTag "otherFoo" [ flag "flag1" ] + ] + ] + +let sb = new StringBuilder() + +// Perform actions on the `sb` object... +sb.AppendLine "This is a HTML snippet inside a markdown string:" + .AppendLine "" + .AppendLine "```html" |> ignore + +let sb' = ViewBuilder.buildHtmlNode sb someHtml + +// Perform more actions on the `sb` object... +sb'.AppendLine "```" |> ignore + +let markdownOutput = sb'.ToString() +``` + ### Common View Engine Features The Giraffe View Engine doesn't have any specially built functions for commonly known features such as master pages or partial views, mainly because the nature of the view engine itself doesn't require it in most cases. @@ -2880,7 +3060,25 @@ Overall the Giraffe View Engine is extremely flexible and feature rich by nature By default Giraffe uses [Newtonsoft JSON.NET](https://www.newtonsoft.com/json) for (de-)serializing JSON content. An application can modify the default serializer by registering a new dependency which implements the `IJsonSerializer` interface during application startup. -Customizing Giraffe's JSON serialization can either happen via providing a custom object of `JsonSerializerSettings` when instantiating the default `NewtonsoftJsonSerializer` or swap in an entire different JSON library by creating a new class which implements the `IJsonSerializer` interface. +Customizing Giraffe's JSON serialization can either happen via providing a custom object of `JsonSerializerSettings` when instantiating the default `NewtonsoftJsonSerializer` or by swapping in an entire different JSON library by creating a new class which implements the `IJsonSerializer` interface. + +By default Giraffe offers two `IJsonSerializer` implementations out of the box: + +| Name | Description | Default | +| :--- | :---------- | :------ | +| `NewtonsoftJsonSerializer` | Uses `Newtonsoft.Json` aka JSON.Net for JSON (de-)serialization in Giraffe. It is the most downloaded library on NuGet, battle tested by millions of users and has great support for F# data types. Use this json serializer for maximum compatibility and easy adoption. | True | +| `Utf8JsonSerializer` | Uses `Utf8Json` for JSON (de-)serialization in Giraffe. This is the fastest JSON serializer written in .NET with huge extensibility points and native support for directly serializing JSON content to the HTTP response stream via chunked encoding. This serializer has been specifically crafted for maximum performance and should be used when that extra perf is important. | False | + +The `Utf8JsonSerializer` can be used instead of the `NewtonsoftJsonSerializer` by registering a new dependency of type `IJsonSerializer` during application configuration: + +```fsharp +let configureServices (services : IServiceCollection) = + // First register all default Giraffe dependencies + services.AddGiraffe() |> ignore + + // Now register Utf8JsonSerializer + this.AddSingleton(Utf8JsonSerializer(Utf8JsonSerializer.DefaultResolver)) |> ignore +``` #### Customizing JsonSerializerSettings @@ -2918,9 +3116,12 @@ You can change the entire underlying JSON serializer by creating a new class whi type CustomJsonSerializer() = interface IJsonSerializer with // Use different JSON library ... - member __.Serialize (o : obj) = // ... + member __.SerializeToString<'T> (x : 'T) = // ... + member __.SerializeToBytes<'T> (x : 'T) = // ... + member __.SerializeToStreamAsync<'T> (x : 'T) = // ... + member __.Deserialize<'T> (json : string) = // ... - member __.Deserialize<'T> (stream : Stream) = // ... + member __.Deserialize<'T> (bytes : byte[]) = // ... member __.DeserializeAsync<'T> (stream : Stream) = // ... ``` @@ -3043,6 +3244,48 @@ let customHandler (dataObj : obj) : HttpHandler = On top of default HTTP related functions such as `HttpContext` extension methods and `HttpHandler` functions Giraffe also provides a few other helper functions which are commonly required in Giraffe web applications. +### Short GUIDs and Short IDs + +The `ShortGuid` and `ShortId` modules offer helper functions to work with [Short GUIDs](https://madskristensen.net/blog/A-shorter-and-URL-friendly-GUID) and [Short IDs](https://webapps.stackexchange.com/questions/54443/format-for-id-of-youtube-video) inside Giraffe. + +#### ShortGuid + +The `ShortGuid.fromGuid` function will convert a `System.Guid` into a URL friendly 22 character long `string` value. + +The `ShortGuid.toGuid` function will convert a 22 character short GUID `string` into a valid `System.Guid` object. This function can be useful when converting a `string` query parameter into a valid `Guid` argument: + +```fsharp +let someHttpHandler : HttpHandler = + fun (next : HttpFunc) (ctx : HttpContext) -> + let guid = + match ctx.TryGetQueryStringValue "id" with + | None -> Guid.Empty + | Some shortGuid -> ShortGuid.toGuid shortGuid + + // Do something with `guid`... + // Return a Task +``` + +#### ShortId + +The `ShortId.fromUInt64` function will convert an `uint64` into a URL friendly 11 character long `string` value. + +The `ShortId.toUInt64` function will convert a 11 character short ID `string` into a `uint64` value. This function can be useful when converting a `string` query parameter into a valid `uint64` argument: + +```fsharp +let someHttpHandler : HttpHandler = + fun (next : HttpFunc) (ctx : HttpContext) -> + let id = + match ctx.TryGetQueryStringValue "id" with + | None -> 0UL + | Some shortId -> ShortId.toUInt64 shortId + + // Do something with `id`... + // Return a Task +``` + +Short GUIDs and short IDs can also be [automatically resolved from route arguments](#routef). + ### Common Helper Functions #### DateTime Extension methods diff --git a/Giraffe.sln b/Giraffe.sln index 03ac8ec1..82b0b7cd 100644 --- a/Giraffe.sln +++ b/Giraffe.sln @@ -7,9 +7,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9E892FBB-74F EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{152E856C-48EA-42A1-A5F4-960819BEC170}" EndProject -Project("{f2a71f9b-5d33-465a-a702-920d77279786}") = "Giraffe", "src\Giraffe\Giraffe.fsproj", "{A16935F3-2E48-4D38-B08C-36E5ADE3B199}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Giraffe", "src\Giraffe\Giraffe.fsproj", "{A16935F3-2E48-4D38-B08C-36E5ADE3B199}" EndProject -Project("{f2a71f9b-5d33-465a-a702-920d77279786}") = "Giraffe.Tests", "tests\Giraffe.Tests\Giraffe.Tests.fsproj", "{2AF14B8E-56FF-4E54-99DA-C530D573814D}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Giraffe.Tests", "tests\Giraffe.Tests\Giraffe.Tests.fsproj", "{2AF14B8E-56FF-4E54-99DA-C530D573814D}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{34A76D42-035A-4CD1-85B2-EC01D9CE3571}" EndProject @@ -45,15 +45,17 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "IdentityApp", "IdentityApp" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GoogleAuthApp", "GoogleAuthApp", "{53C796EB-20D2-4AF9-AAB9-130150005E21}" EndProject -Project("{f2a71f9b-5d33-465a-a702-920d77279786}") = "GoogleAuthApp", "samples\GoogleAuthApp\GoogleAuthApp\GoogleAuthApp.fsproj", "{FE396475-56EA-48AC-87B8-97EF6A66612F}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "GoogleAuthApp", "samples\GoogleAuthApp\GoogleAuthApp\GoogleAuthApp.fsproj", "{FE396475-56EA-48AC-87B8-97EF6A66612F}" EndProject -Project("{f2a71f9b-5d33-465a-a702-920d77279786}") = "IdentityApp", "samples\IdentityApp\IdentityApp\IdentityApp.fsproj", "{3AAA2ECF-350F-4574-925F-21A909F41F42}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "IdentityApp", "samples\IdentityApp\IdentityApp\IdentityApp.fsproj", "{3AAA2ECF-350F-4574-925F-21A909F41F42}" EndProject -Project("{f2a71f9b-5d33-465a-a702-920d77279786}") = "JwtApp", "samples\JwtApp\JwtApp\JwtApp.fsproj", "{BCD0E9C4-62AB-45B2-8362-A7AD1E4C03A7}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "JwtApp", "samples\JwtApp\JwtApp\JwtApp.fsproj", "{BCD0E9C4-62AB-45B2-8362-A7AD1E4C03A7}" EndProject -Project("{f2a71f9b-5d33-465a-a702-920d77279786}") = "SampleApp", "samples\SampleApp\SampleApp\SampleApp.fsproj", "{61E7381C-C021-4048-A6F3-542E190F0D48}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "SampleApp", "samples\SampleApp\SampleApp\SampleApp.fsproj", "{61E7381C-C021-4048-A6F3-542E190F0D48}" EndProject -Project("{f2a71f9b-5d33-465a-a702-920d77279786}") = "SampleApp.Tests", "samples\SampleApp\SampleApp.Tests\SampleApp.Tests.fsproj", "{A878E197-31F3-4DBB-B8CC-6FBB4A53733E}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "SampleApp.Tests", "samples\SampleApp\SampleApp.Tests\SampleApp.Tests.fsproj", "{A878E197-31F3-4DBB-B8CC-6FBB4A53733E}" +EndProject +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Giraffe.Benchmarks", "tests\Giraffe.Benchmarks\Giraffe.Benchmarks.fsproj", "{2188E634-B828-4629-89B9-3680422460F5}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -64,9 +66,6 @@ Global Release|x64 = Release|x64 Release|x86 = Release|x86 EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A16935F3-2E48-4D38-B08C-36E5ADE3B199}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A16935F3-2E48-4D38-B08C-36E5ADE3B199}.Debug|Any CPU.Build.0 = Debug|Any CPU @@ -152,6 +151,21 @@ Global {A878E197-31F3-4DBB-B8CC-6FBB4A53733E}.Release|x64.Build.0 = Release|Any CPU {A878E197-31F3-4DBB-B8CC-6FBB4A53733E}.Release|x86.ActiveCfg = Release|Any CPU {A878E197-31F3-4DBB-B8CC-6FBB4A53733E}.Release|x86.Build.0 = Release|Any CPU + {2188E634-B828-4629-89B9-3680422460F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2188E634-B828-4629-89B9-3680422460F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2188E634-B828-4629-89B9-3680422460F5}.Debug|x64.ActiveCfg = Debug|Any CPU + {2188E634-B828-4629-89B9-3680422460F5}.Debug|x64.Build.0 = Debug|Any CPU + {2188E634-B828-4629-89B9-3680422460F5}.Debug|x86.ActiveCfg = Debug|Any CPU + {2188E634-B828-4629-89B9-3680422460F5}.Debug|x86.Build.0 = Debug|Any CPU + {2188E634-B828-4629-89B9-3680422460F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2188E634-B828-4629-89B9-3680422460F5}.Release|Any CPU.Build.0 = Release|Any CPU + {2188E634-B828-4629-89B9-3680422460F5}.Release|x64.ActiveCfg = Release|Any CPU + {2188E634-B828-4629-89B9-3680422460F5}.Release|x64.Build.0 = Release|Any CPU + {2188E634-B828-4629-89B9-3680422460F5}.Release|x86.ActiveCfg = Release|Any CPU + {2188E634-B828-4629-89B9-3680422460F5}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {A16935F3-2E48-4D38-B08C-36E5ADE3B199} = {9E892FBB-74FD-464B-8939-6E4D9D70D99D} @@ -165,5 +179,9 @@ Global {BCD0E9C4-62AB-45B2-8362-A7AD1E4C03A7} = {8AC0E934-6177-4D6D-9520-6E0931E527B9} {61E7381C-C021-4048-A6F3-542E190F0D48} = {E3500770-59F9-4526-9FFA-73E725C0008F} {A878E197-31F3-4DBB-B8CC-6FBB4A53733E} = {E3500770-59F9-4526-9FFA-73E725C0008F} + {2188E634-B828-4629-89B9-3680422460F5} = {152E856C-48EA-42A1-A5F4-960819BEC170} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {77FFE315-7928-4985-B60D-8009AE1A3805} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index ecb1bc9c..39bb9f1e 100644 --- a/README.md +++ b/README.md @@ -61,15 +61,32 @@ For more information about the Giraffe template please visit the official [giraf ### Doing it manually -Install the [Giraffe](https://www.nuget.org/packages/Giraffe) NuGet package: +Install the [Giraffe](https://www.nuget.org/packages/Giraffe) NuGet package*: ``` PM> Install-Package Giraffe ``` -Create a web application and plug it into the ASP.NET Core middleware: +*) If you haven't installed the ASP.NET Core NuGet package yet then you'll also need to add a package reference to `Microsoft.AspNetCore.App`: + +``` +PM> Install-Package Microsoft.AspNetCore.App +``` + +Alternatively you can also use the .NET CLI to add the packages: + +``` +dotnet add [PROJECT] package Microsoft.AspNetCore.App --package-directory [PACKAGE_CIRECTORY] +dotnet add [PROJECT] package Giraffe --package-directory [PACKAGE_CIRECTORY] +``` + +Next create a web application and plug it into the ASP.NET Core middleware: ```fsharp +open System +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Hosting +open Microsoft.Extensions.DependencyInjection open Giraffe let webApp = @@ -101,6 +118,10 @@ let main _ = Instead of creating a `Startup` class you can also add Giraffe in a more functional way: ```fsharp +open System +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Hosting +open Microsoft.Extensions.DependencyInjection open Giraffe let webApp = @@ -152,9 +173,15 @@ More sample applications will be added in the future. ## Benchmarks -Currently Giraffe has only been tested against a simple plain text route and measured the total amount of handled requests per second. The latest result yielded an average of 79093 req/s over a period of 10 seconds, which was only closely after plain Kestrel which was capable of handling 79399 req/s on average. +Giraffe is part of the [TechEmpower Web Framework Benchmarks](https://www.techempower.com/benchmarks/#section=test&runid=a1843d12-6091-4780-92a6-a747fab77cb1&hw=ph&test=plaintext&l=hra0hp-1&p=zik0zj-zik0zj-zijocf-5m9r) and will be listed in the official results page in the upcoming Round 17 for the first time. + +Unofficial test results are currently available on the [TFB Status page](https://tfb-status.techempower.com/). + +As of today Giraffe competes in the Plaintext, JSON and Fortunes categories and has been doing pretty well so far, even outperforming ASP.NET Core MVC in Plaintext and JSON at the time of writing. + +The latest implementation which is being used for the benchmark tests can be seen inside the [TechEmpower repository](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/FSharp/giraffe). -Please check out [Jimmy Byrd](https://github.com/TheAngryByrd)'s [dotnet-web-benchmarks](https://github.com/TheAngryByrd/dotnet-web-benchmarks) for more details. +Giraffe is also featured in [Jimmy Byrd](https://github.com/TheAngryByrd)'s [dotnet-web-benchmarks](https://github.com/TheAngryByrd/dotnet-web-benchmarks) where we've run earlier performance tests. ## Building and developing diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d8cfcadc..5f35699f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,44 @@ Release Notes ============= +## 3.0.0 + +#### Breaking changes + +- Changed the type `XmlNode` by removing the `RawText` and `EncodedText` union case and replaced both by a single `Text` union case. The HTML encoding (or not) is being done now when calling one of the two helper functions `rawText` and `encodedText`. + + - This change - even though theoretically a breaking change - should not affect the vast majority of Giraffe users unless you were constructing your own `XmlNode` elements which were of type `RawText` or `EncodedText` (which is extremely unlikely given that there's not much room for more nodes of these two types). + +- Removed the `task {}` override in Giraffe which was forcing the `FSharp.Control.Tasks.V2.ContextInsensitive` version of the Task CE. This change has no effect on the behaviour of `task` computation expressions in Giraffe. In the context of an ASP.NET Core web application there is not difference between `ContextSensitive` and `ContextInsensitive` which is why the override has been removed. The only breaking change which could affect an existing Giraffe web application is that in some places you will need to explicitly `open FSharp.Control.Tasks.V2.ContextInsensitive` where before it might have been sufficient to only `open Giraffe`. + +- Changed the members of the `IJsonSerializer` interface to accommodate new (de-)serialize methods for chunked encoding transfer. + + The new interface is the following: + + ```fsharp + type IJsonSerializer = + abstract member SerializeToString<'T> : 'T -> string + abstract member SerializeToBytes<'T> : 'T -> byte array + abstract member SerializeToStreamAsync<'T> : 'T -> Stream -> Task + + abstract member Deserialize<'T> : string -> 'T + abstract member Deserialize<'T> : byte[] -> 'T + abstract member DeserializeAsync<'T> : Stream -> Task<'T> + ``` + +#### Improvements + +- Significant performance improvements in the `GiraffeViewEngine` by changing the underlying composition of views from simple string concatenation to using a `StringBuilder` object. + +#### New features + +- Support for short GUIDs and short IDs (aka YouTube IDs) [in route arguments](https://github.com/giraffe-fsharp/Giraffe/blob/master/DOCUMENTATION.md#routef) and [query string parameters](https://github.com/giraffe-fsharp/Giraffe/blob/master/DOCUMENTATION.md#short-guids-and-short-ids). +- Enabled [SourceLink](https://github.com/dotnet/sourcelink/) support for Giraffe source code (thanks [Cameron Taggart](https://github.com/ctaggart))! For more information check out [Adding SourceLink to your .NET Core Library](https://carlos.mendible.com/2018/08/25/adding-sourcelink-to-your-net-core-library/). +- Added a new JSON serializer called `Utf8JsonSerializer`. This type uses the [Utf8 JSON serializer library](https://github.com/neuecc/Utf8Json/), which is currently the fastest JSON serializer for .NET. `NewtonsoftJsonSerializer` is still the default JSON serializer in Giraffe (for stability and backwards compatibility), but `Utf8JsonSerializer` can be swapped in via [ASP.NET Core's dependency injection API](https://github.com/giraffe-fsharp/Giraffe/blob/master/DOCUMENTATION.md#json). The new `Utf8JsonnSerializer` is significantly faster (especially when sending chunked responses) than `NewtonsoftJsonSerializer`. +- Added a new `HttpContext` extension method for chunked JSON transfers: `WriteJsonChunkedAsync<'T> (dataObj : 'T)`. This new `HttpContext` method can write content directly to the HTTP response stream without buffering into a byte array first (see [Writing JSON](https://github.com/giraffe-fsharp/Giraffe/blob/master/DOCUMENTATION.md#writing-json)). +- Added a new `jsonChunked` http handler. This handler is the equivalent http handler version of the `WriteJsonChunkedAsync` extension method. +- Added first class support for [ASP.NET Core's response caching](https://github.com/giraffe-fsharp/Giraffe/blob/master/DOCUMENTATION.md#response-caching) feature. + ## 2.0.1 Changed the `task {}` CE to load from `FSharp.Control.Tasks.V2.ContextInsensitive` instead of `FSharp.Control.Tasks.ContextInsensitive`. @@ -21,7 +59,7 @@ Changed the `task {}` CE to load from `FSharp.Control.Tasks.V2.ContextInsensitiv - Enabled `return!` for `opt { }` computation expressions. - Added `blockquote`, `_integrity` and `_scoped` to the `GiraffeViewEngine`. - Added attributes for mouse, keyboard, touch, drag & drop, focus, input and mouse wheel events to the `GiraffeViewEngine`. -- Added new accessibility attributes to the `GriaffeViewEngine`. These can be used after opening the `Giraffe.GiraffeViewEngine.Accessibility` module. +- Added new accessibility attributes to the `GiraffeViewEngine`. These can be used after opening the `Giraffe.GiraffeViewEngine.Accessibility` module. - Added a new `Successful.NO_CONTENT` http handler which can be used to return a HTTP 204 response. - Added more structured logging around the Giraffe middleware. diff --git a/build.ps1 b/build.ps1 index e25fd409..ec13467c 100644 --- a/build.ps1 +++ b/build.ps1 @@ -18,13 +18,9 @@ param $ErrorActionPreference = "Stop" -Write-Host "" -Write-Host "--------------------------------" -ForegroundColor DarkYellow -Write-Host " Starting Giraffe build script " -ForegroundColor DarkYellow -Write-Host "--------------------------------" -ForegroundColor DarkYellow -Write-Host "" +Import-module "$PSScriptRoot/.psscripts/build-functions.ps1" -Force -Import-module "$pwd\.psscripts\build-functions.ps1" -Force +Write-BuildHeader "Starting Giraffe build script" if ($ClearOnly.IsPresent) { @@ -32,14 +28,15 @@ if ($ClearOnly.IsPresent) return } -$giraffe = ".\src\Giraffe\Giraffe.fsproj" -$giraffeTests = ".\tests\Giraffe.Tests\Giraffe.Tests.fsproj" -$identityApp = ".\samples\IdentityApp\IdentityApp\IdentityApp.fsproj" -$jwtApp = ".\samples\JwtApp\JwtApp\JwtApp.fsproj" -$sampleApp = ".\samples\SampleApp\SampleApp\SampleApp.fsproj" -$sampleAppTests = ".\samples\SampleApp\SampleApp.Tests\SampleApp.Tests.fsproj" +$giraffe = "./src/Giraffe/Giraffe.fsproj" +$giraffeTests = "./tests/Giraffe.Tests/Giraffe.Tests.fsproj" +$identityApp = "./samples/IdentityApp/IdentityApp/IdentityApp.fsproj" +$jwtApp = "./samples/JwtApp/JwtApp/JwtApp.fsproj" +$sampleApp = "./samples/SampleApp/SampleApp/SampleApp.fsproj" +$sampleAppTests = "./samples/SampleApp/SampleApp.Tests/SampleApp.Tests.fsproj" -Update-AppVeyorBuildVersion $giraffe +$version = Get-ProjectVersion $giraffe +Update-AppVeyorBuildVersion $version if (Test-IsAppVeyorBuildTriggeredByGitTag) { @@ -90,8 +87,4 @@ if ($Pack.IsPresent) dotnet-pack $giraffe "-c $configuration" } -Write-Host "" -Write-Host " .~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~. " -ForegroundColor Green -Write-Host " Giraffe build completed successfully! " -ForegroundColor Green -Write-Host " '~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~' " -ForegroundColor Green -Write-Host "" \ No newline at end of file +Write-SuccessFooter "Giraffe build completed successfully!" \ No newline at end of file diff --git a/global.json b/global.json index b11d921c..a24bb130 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "projects": [ "src", "tests" ], "sdk": { - "version": "2.1.400" + "version": "2.1.402" } } \ No newline at end of file diff --git a/samples/SampleApp/SampleApp.Tests/Program.fs b/samples/SampleApp/SampleApp.Tests/Program.fs deleted file mode 100644 index 75f178df..00000000 --- a/samples/SampleApp/SampleApp.Tests/Program.fs +++ /dev/null @@ -1,2 +0,0 @@ -module Program -let [] main _ = 0 \ No newline at end of file diff --git a/samples/SampleApp/SampleApp.Tests/SampleApp.Tests.fsproj b/samples/SampleApp/SampleApp.Tests/SampleApp.Tests.fsproj index c8251c6b..0b66c7a6 100644 --- a/samples/SampleApp/SampleApp.Tests/SampleApp.Tests.fsproj +++ b/samples/SampleApp/SampleApp.Tests/SampleApp.Tests.fsproj @@ -9,7 +9,7 @@ - + @@ -20,7 +20,6 @@ - \ No newline at end of file diff --git a/samples/SampleApp/SampleApp/Program.fs b/samples/SampleApp/SampleApp/Program.fs index 813de2ea..7dfe4607 100644 --- a/samples/SampleApp/SampleApp/Program.fs +++ b/samples/SampleApp/SampleApp/Program.fs @@ -98,6 +98,22 @@ let fileUploadHandler2 = |> text) next ctx } +let cacheHandler1 : HttpHandler = + publicResponseCaching 30 None + >=> warbler (fun _ -> + text (Guid.NewGuid().ToString())) + +let cacheHandler2 : HttpHandler = + responseCaching + (Public (TimeSpan.FromSeconds (float 30))) + None + (Some [| "key1"; "key2" |]) + >=> warbler (fun _ -> + text (Guid.NewGuid().ToString())) + +let cacheHandler3 : HttpHandler = + noResponseCaching >=> warbler (fun _ -> text (Guid.NewGuid().ToString())) + let time() = System.DateTime.Now.ToString() [] @@ -133,6 +149,9 @@ let webApp = route "/configured" >=> configuredHandler route "/upload" >=> fileUploadHandler route "/upload2" >=> fileUploadHandler2 + route "/cache/1" >=> cacheHandler1 + route "/cache/2" >=> cacheHandler2 + route "/cache/3" >=> cacheHandler3 ] route "/car" >=> bindModel None json route "/car2" >=> tryBindQuery parsingErrorHandler None (validateModel xml) @@ -153,10 +172,12 @@ let configureApp (app : IApplicationBuilder) = app.UseGiraffeErrorHandler(errorHandler) .UseStaticFiles() .UseAuthentication() + .UseResponseCaching() .UseGiraffe webApp let configureServices (services : IServiceCollection) = services + .AddResponseCaching() .AddGiraffe() .AddAuthentication(authScheme) .AddCookie(cookieAuth) |> ignore diff --git a/src/Giraffe/Common.fs b/src/Giraffe/Common.fs index 1fe36820..146e6584 100644 --- a/src/Giraffe/Common.fs +++ b/src/Giraffe/Common.fs @@ -3,16 +3,7 @@ module Giraffe.Common open System open System.IO - -// --------------------------- -// Override the default task CE -// --------------------------- - -/// **Context insensitive Task CE** -/// -/// All tasks are configured with `ConfigurAwait(false)`. -/// -let task = FSharp.Control.Tasks.V2.ContextInsensitive.task +open FSharp.Control.Tasks.V2.ContextInsensitive // --------------------------- // Useful extension methods @@ -97,4 +88,143 @@ let readFileAsStringAsync (filePath : string) = task { use reader = new StreamReader(filePath) return! reader.ReadToEndAsync() - } \ No newline at end of file + } + +// --------------------------- +// Short GUIDs and IDs +// --------------------------- + +/// **Description** +/// +/// Short GUIDs are a shorter, URL-friendlier version +/// of the traditional `System.Guid` type. +/// +/// Short GUIDs are always 22 characters long, which let's +/// one save a total of 10 characters in comparison to using +/// a normal `System.Guid` as identifier. +/// +/// Additionally a Short GUID is by default a URL encoded +/// string which doesn't need extra character replacing +/// before using of it in a URL query parameter. +/// +/// All Short GUID strings map directly to a `System.Guid` +/// objet and the `ShortGuid` module can be used to convert +/// a `System.Guid` into a short GUID `string` and vice versa. +/// +/// For more information please check: +/// https://madskristensen.net/blog/A-shorter-and-URL-friendly-GUID +/// +[] +module ShortGuid = + + /// **Description** + /// + /// Converts a `System.Guid` into a 22 character long + /// short GUID string. + /// + /// **Parameters** + /// + /// - `guid`: The `System.Guid` to be converted into a short GUID. + /// + /// **Output** + /// + /// Returns a 22 character long URL encoded short GUID string. + /// + let fromGuid (guid : Guid) = + guid.ToByteArray() + |> Convert.ToBase64String + |> (fun str -> + str.Replace("/", "_") + .Replace("+", "-") + .Substring(0, 22)) + + /// **Description** + /// + /// Converts a 22 character short GUID string into the matching `System.Guid`. + /// + /// **Parameters** + /// + /// - `shortGuid`: The short GUID string to be converted into a `System.Guid`. + /// + /// **Output** + /// + /// Returns a `System.Guid` object. + /// + let toGuid (shortGuid : string) = + shortGuid.Replace("_", "/") + .Replace("-", "+") + |> (fun str -> str + "==") + |> Convert.FromBase64String + |> Guid + +/// **Description** +/// +/// Short IDs are a shorter, URL-friendlier version +/// of an unisgned 64-bit integer value (`uint64` in F# and `ulong` in C#). +/// +/// Short IDs are always 11 characters long, which let's +/// one save a total of 9 characters in comparison to using +/// a normal `uint64` value as identifier. +/// +/// Additionally a Short ID is by default a URL encoded +/// string which doesn't need extra character replacing +/// before using it in a URL query parameter. +/// +/// All Short ID strings map directly to a `uint64` object +/// and the `ShortId` module can be used to convert an +/// `unint64` value into a short ID `string` and vice versa. +/// +/// For more information please check: +/// https://webapps.stackexchange.com/questions/54443/format-for-id-of-youtube-video +/// +[] +module ShortId = + + /// **Description** + /// + /// Converts a `uint64` value into a 11 character long + /// short ID string. + /// + /// **Parameters** + /// + /// - `id`: The `uint64` to be converted into a short ID. + /// + /// **Output** + /// + /// Returns a 11 character long URL encoded short ID string. + /// + let fromUInt64 (id : uint64) = + BitConverter.GetBytes id + |> (fun arr -> + match BitConverter.IsLittleEndian with + | true -> Array.Reverse arr; arr + | false -> arr) + |> Convert.ToBase64String + |> (fun str -> + str.Remove(11, 1) + .Replace("/", "_") + .Replace("+", "-")) + + /// **Description** + /// + /// Converts a 11 character short ID string into the matching `uint64` value. + /// + /// **Parameters** + /// + /// - `shortId`: The short ID string to be converted into a `uint64` value. + /// + /// **Output** + /// + /// Returns a `uint64` value. + /// + let toUInt64 (shortId : string) = + let bytes = + shortId.Replace("_", "/") + .Replace("-", "+") + |> (fun str -> str + "=") + |> Convert.FromBase64String + |> (fun arr -> + match BitConverter.IsLittleEndian with + | true -> Array.Reverse arr; arr + | false -> arr) + BitConverter.ToUInt64 (bytes, 0) \ No newline at end of file diff --git a/src/Giraffe/ComputationExpressions.fs b/src/Giraffe/ComputationExpressions.fs index 33f28f01..a9bc2b8c 100644 --- a/src/Giraffe/ComputationExpressions.fs +++ b/src/Giraffe/ComputationExpressions.fs @@ -27,4 +27,4 @@ type ResultBuilder() = /// /// Enables control flow and binding of `Result<'T, 'TError>` objects /// -let res = ResultBuilder() +let res = ResultBuilder() \ No newline at end of file diff --git a/src/Giraffe/FormatExpressions.fs b/src/Giraffe/FormatExpressions.fs index 2e60bd69..26caad55 100644 --- a/src/Giraffe/FormatExpressions.fs +++ b/src/Giraffe/FormatExpressions.fs @@ -1,7 +1,6 @@ module Giraffe.FormatExpressions open System -open System.Net open System.Text.RegularExpressions open FSharp.Core open Microsoft.FSharp.Reflection @@ -23,8 +22,15 @@ let private formatStringMap = // https://github.com/aspnet/Mvc/issues/4599 str.Replace("%2F", "/").Replace("%2f", "/") - let guidFormatStr = - "([0-9A-Fa-f]{8}\-[0-9A-Fa-f]{4}\-[0-9A-Fa-f]{4}\-[0-9A-Fa-f]{4}\-[0-9A-Fa-f]{12}|[0-9A-Fa-f]{32})" + let parseGuid (str : string) = + match str.Length with + | 22 -> ShortGuid.toGuid str + | _ -> Guid str + + let guidPattern = + "([0-9A-Fa-f]{8}\-[0-9A-Fa-f]{4}\-[0-9A-Fa-f]{4}\-[0-9A-Fa-f]{4}\-[0-9A-Fa-f]{12}|[0-9A-Fa-f]{32}|[-_0-9A-Za-z]{22})" + + let shortIdPattern = "([-_0-9A-Za-z]{10}[048AEIMQUYcgkosw])" dict [ // Char Regex Parser @@ -35,7 +41,8 @@ let private formatStringMap = 'i', ("(-?\d+)", int32 >> box) // int 'd', ("(-?\d+)", int64 >> box) // int64 'f', ("(-?\d+\.{1}\d+)", float >> box) // float - 'O', (guidFormatStr, Guid >> box) // Guid + 'O', (guidPattern, parseGuid >> box) // Guid + 'u', (shortIdPattern, ShortId.toUInt64 >> box) // uint64 ] let private convertToRegexPatternAndFormatChars (formatString : string) = @@ -142,6 +149,7 @@ let validateFormat (format : PrintfFormat<_,_,_,_, 'T>) = 'd' , typeof // int64 'f' , typeof // float 'O' , typeof // guid + 'u' , typeof // guid ] let tuplePrint pos last name = diff --git a/src/Giraffe/Giraffe.fsproj b/src/Giraffe/Giraffe.fsproj index 9eb0a7a9..1d531c71 100644 --- a/src/Giraffe/Giraffe.fsproj +++ b/src/Giraffe/Giraffe.fsproj @@ -3,7 +3,7 @@ Giraffe - 2.0.1 + 3.0.0 A native functional ASP.NET Core web framework for F# developers. Copyright 2018 Dustin Moris Gorski Dustin Moris Gorski and contributors @@ -13,7 +13,6 @@ net461;netstandard2.0 portable Library - true true false true @@ -29,16 +28,25 @@ true git https://github.com/dustinmoris/giraffe + + + true + true + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + FS2003 + + + @@ -48,6 +56,7 @@ + diff --git a/src/Giraffe/GiraffeViewEngine.fs b/src/Giraffe/GiraffeViewEngine.fs index d782c68f..eacdff95 100644 --- a/src/Giraffe/GiraffeViewEngine.fs +++ b/src/Giraffe/GiraffeViewEngine.fs @@ -17,6 +17,7 @@ module Giraffe.GiraffeViewEngine open System open System.Net +open System.Text // --------------------------- // Definition of different HTML content @@ -35,14 +36,19 @@ type XmlElement = string * XmlAttribute[] // Name * XML attributes type XmlNode = | ParentNode of XmlElement * XmlNode list // An XML element which contains nested XML elements | VoidElement of XmlElement // An XML element which cannot contain nested XML (e.g.
or
) - | EncodedText of string // XML encoded text content - | RawText of string // Raw text content + | Text of string // Text content + +// --------------------------- +// Helper functions +// --------------------------- + +let inline private encode v = WebUtility.HtmlEncode v // --------------------------- // Building blocks // --------------------------- -let attr (key : string) (value : string) = KeyValue (key, value) +let attr (key : string) (value : string) = KeyValue (key, encode value) let flag (key : string) = Boolean key let tag (tagName : string) @@ -54,8 +60,8 @@ let voidTag (tagName : string) (attributes : XmlAttribute list) = VoidElement (tagName, Array.ofList attributes) -let encodedText (content : string) = EncodedText content -let rawText (content : string) = RawText content +let encodedText (content : string) = Text (encode content) +let rawText (content : string) = Text content let emptyText = rawText "" let comment (content : string) = rawText (sprintf "" content) @@ -365,7 +371,7 @@ module Attributes = /// Attributes to support WAI-ARIA accessibility guidelines module Accessibility = - + // Valid role attributes // (obtained from https://www.w3.org/TR/wai-aria/#role_definitions) let _roleAlert = attr "role" "alert" @@ -436,7 +442,7 @@ module Accessibility = let _roleTree = attr "role" "tree" let _roleTreeGrid = attr "role" "treegrid" let _roleTreeItem = attr "role" "treeitem" - + // Valid aria attributes // (obtained from https://www.w3.org/TR/wai-aria/#state_prop_def) let _ariaActiveDescendant = attr "aria-activedescendant" @@ -488,61 +494,74 @@ module Accessibility = let _ariaValueNow = attr "aria-valuenow" let _ariaValueText = attr "aria-valuetext" +// --------------------------- +// Build HTML/XML views +// --------------------------- + +[] +module ViewBuilder = + let inline private (+=) (sb : StringBuilder) (text : string) = sb.Append(text) + let inline private (+!) (sb : StringBuilder) (text : string) = sb.Append(text) |> ignore + + let inline private selfClosingBracket (isHtml : bool) = + if isHtml then ">" else " />" + + let rec private buildNode (isHtml : bool) (sb : StringBuilder) (node : XmlNode) : unit = + + let buildElement closingBracket (elemName, attributes : XmlAttribute array) = + match attributes with + | [||] -> do sb += "<" += elemName +! closingBracket + | _ -> + do sb += "<" +! elemName + + attributes + |> Array.iter (fun attr -> + match attr with + | KeyValue (k, v) -> do sb += " " += k += "=\"" += v +! "\"" + | Boolean k -> do sb += " " +! k) + + do sb +! closingBracket + + let inline buildParentNode (elemName, attributes : XmlAttribute array) (nodes : XmlNode list) = + do buildElement ">" (elemName, attributes) + for node in nodes do buildNode isHtml sb node + do sb += "" + + match node with + | Text text -> do sb +! text + | ParentNode (e, nodes) -> do buildParentNode e nodes + | VoidElement e -> do buildElement (selfClosingBracket isHtml) e + + let buildXmlNode = buildNode false + let buildHtmlNode = buildNode true + + let buildXmlNodes sb (nodes : XmlNode list) = for n in nodes do buildXmlNode sb n + let buildHtmlNodes sb (nodes : XmlNode list) = for n in nodes do buildHtmlNode sb n + + let buildHtmlDocument sb (document : XmlNode) = + sb += "" +! Environment.NewLine + buildHtmlNode sb document // --------------------------- -// Render XML string +// Render HTML/XML views // --------------------------- -let rec private nodeToString (htmlStyle : bool) (node : XmlNode) = - let startElementToString selfClosing (elemName, attributes : XmlAttribute array) = - let closingBracket = - match selfClosing with - | false -> ">" - | true -> - match htmlStyle with - | false -> " />" - | true -> ">" - match attributes with - | [||] -> sprintf "<%s%s" elemName closingBracket - | _ -> - attributes - |> Array.map (fun attr -> - match attr with - | KeyValue (k, v) -> sprintf " %s=\"%s\"" k (WebUtility.HtmlEncode v) - | Boolean k -> sprintf " %s" k) - |> String.Concat - |> sprintf "<%s%s%s" elemName - <| closingBracket - - let endElementToString (elemName, _) = sprintf "" elemName - - let parentNodeToString (elem : XmlElement, nodes : XmlNode list) = - let innerContent = nodes |> List.map (nodeToString htmlStyle) |> String.Concat - let startTag = elem |> startElementToString false - let endTag = elem |> endElementToString - sprintf "%s%s%s" startTag innerContent endTag - - match node with - | EncodedText text -> WebUtility.HtmlEncode text - | RawText text -> text - | ParentNode (e, nodes) -> parentNodeToString (e, nodes) - | VoidElement e -> startElementToString true e - -let renderXmlNode = nodeToString false - -let renderXmlNodes (nodes : XmlNode list) = - nodes - |> List.map renderXmlNode - |> String.Concat - -let renderHtmlNode = nodeToString true - -let renderHtmlNodes (nodes : XmlNode list) = - nodes - |> List.map renderHtmlNode - |> String.Concat - -let renderHtmlDocument (document : XmlNode) = - document - |> renderHtmlNode - |> sprintf "%s%s" Environment.NewLine +let renderXmlNode (node : XmlNode) : string = + let sb = new StringBuilder() in ViewBuilder.buildXmlNode sb node + sb.ToString() + +let renderXmlNodes (nodes : XmlNode list) : string = + let sb = new StringBuilder() in ViewBuilder.buildXmlNodes sb nodes + sb.ToString() + +let renderHtmlNode (node : XmlNode) : string = + let sb = new StringBuilder() in ViewBuilder.buildHtmlNode sb node + sb.ToString() + +let renderHtmlNodes (nodes : XmlNode list) : string = + let sb = new StringBuilder() in ViewBuilder.buildHtmlNodes sb nodes + sb.ToString() + +let renderHtmlDocument (document : XmlNode) : string = + let sb = new StringBuilder() in ViewBuilder.buildHtmlDocument sb document + sb.ToString() \ No newline at end of file diff --git a/src/Giraffe/ResponseCaching.fs b/src/Giraffe/ResponseCaching.fs new file mode 100644 index 00000000..90ff3e20 --- /dev/null +++ b/src/Giraffe/ResponseCaching.fs @@ -0,0 +1,117 @@ +[] +module Giraffe.ResponseCaching + +open System +open Microsoft.Extensions.Primitives +open Microsoft.Net.Http.Headers +open Microsoft.AspNetCore.Http +open Microsoft.AspNetCore.ResponseCaching + +/// **Description** +/// +/// Specifies the directive for the `Cache-Control` HTTP header: +/// +/// - `NoCache`: The resource should not be cached under any circumstances. +/// - `Public`: Any client and proxy may cache the resource for the given amount of time. +/// - `Private`: Only the end client may cache the resource for the given amount of time. +/// +type CacheDirective = + | NoCache + | Public of TimeSpan + | Private of TimeSpan + +let private noCacheHeader = CacheControlHeaderValue(NoCache = true, NoStore = true) + +let inline private cacheHeader isPublic duration = + CacheControlHeaderValue( + Public = isPublic, + MaxAge = Nullable duration) + +/// **Description** +/// +/// Enables (or suppresses) response caching by clients and proxy servers. +/// This http handler integrates with ASP.NET Core's response caching middleware. +/// +/// The `responseCaching` http handler will set the relevant HTTP response headers in order to enable response caching on the client, by proxies (if public) and by the ASP.NET Core middleware (if enabled). +/// +/// **Parameters** +/// +/// - `directive`: Specifies the cache directive to be set in the response's HTTP headers. Use `NoCache` to suppress caching altogether or use `Public`/`Private` to enable caching for everyone or clients only. +/// - `vary`: Optionally specify which HTTP headers have to match in order to return a cached response (e.g. `Accept` and/or `Accept-Encoding`). +/// - `varyByQueryKeys`: An optional list of query keys which will be used by the ASP.NET Core response caching middleware to vary (potentially) cached responses. If this parameter is used then the ASP.NET Core response caching middleware has to be enabled. For more information check the official [VaryByQueryKeys](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/middleware?view=aspnetcore-2.1#varybyquerykeys) documentation. +/// +/// **Output** +/// +/// A Giraffe `HttpHandler` function which can be composed into a bigger web application. +/// +let responseCaching (directive : CacheDirective) + (vary : string option) + (varyByQueryKeys : string array option) : HttpHandler = + fun (next : HttpFunc) (ctx : HttpContext) -> + + let tHeaders = ctx.Response.GetTypedHeaders() + let headers = ctx.Response.Headers + + match directive with + | NoCache -> + tHeaders.CacheControl <- noCacheHeader + headers.[ HeaderNames.Pragma ] <- StringValues [| "no-cache" |] + headers.[ HeaderNames.Expires ] <- StringValues [| "-1" |] + | Public duration -> tHeaders.CacheControl <- cacheHeader true duration + | Private duration -> tHeaders.CacheControl <- cacheHeader false duration + + if vary.IsSome then headers.[HeaderNames.Vary] <- StringValues [| vary.Value |] + + if varyByQueryKeys.IsSome then + let responseCachingFeature = ctx.Features.Get() + if isNotNull responseCachingFeature then + responseCachingFeature.VaryByQueryKeys <- varyByQueryKeys.Value + + next ctx + +/// **Description** +/// +/// Disables response caching by clients and proxy servers. +/// +/// **Output** +/// +/// A Giraffe `HttpHandler` function which can be composed into a bigger web application. +/// +let noResponseCaching : HttpHandler = responseCaching NoCache None None + +/// **Description** +/// +/// Enables response caching for clients and proxy servers. +/// This http handler integrates with ASP.NET Core's response caching middleware. +/// +/// The `responseCaching` http handler will set the relevant HTTP response headers in order to enable response caching on the client, by proxies and by the ASP.NET Core middleware (if enabled). +/// +/// **Parameters** +/// +/// - `seconds`: Specifies the duration (in seconds) for which the response may be cached. +/// - `vary`: Optionally specify which HTTP headers have to match in order to return a cached response (e.g. `Accept` and/or `Accept-Encoding`). +/// +/// **Output** +/// +/// A Giraffe `HttpHandler` function which can be composed into a bigger web application. +/// +let privateResponseCaching (seconds : int) (vary : string option) : HttpHandler = + responseCaching (Private (TimeSpan.FromSeconds(float seconds))) vary None + +/// **Description** +/// +/// Enables response caching for clients only. +/// +/// The `responseCaching` http handler will set the relevant HTTP response headers in order to enable response caching on the client only. +/// +/// **Parameters** +/// +/// - `seconds`: Specifies the duration (in seconds) for which the response may be cached. +/// - `vary`: Optionally specify which HTTP headers have to match in order to return a cached response (e.g. `Accept` and/or `Accept-Encoding`). +/// +/// **Output** +/// +/// A Giraffe `HttpHandler` function which can be composed into a bigger web application. +/// +let publicResponseCaching (seconds : int) (vary : string option) : HttpHandler = + responseCaching (Public (TimeSpan.FromSeconds(float seconds))) vary None \ No newline at end of file diff --git a/src/Giraffe/ResponseWriters.fs b/src/Giraffe/ResponseWriters.fs index 7000b9d2..d898ba9d 100644 --- a/src/Giraffe/ResponseWriters.fs +++ b/src/Giraffe/ResponseWriters.fs @@ -3,6 +3,7 @@ module Giraffe.ResponseWriters open System.IO open System.Text +open System.Buffers open Microsoft.AspNetCore.Http open Microsoft.Net.Http.Headers open FSharp.Control.Tasks.V2.ContextInsensitive @@ -79,11 +80,36 @@ type HttpContext with /// /// Task of `Some HttpContext` after writing to the body of the response. /// - member this.WriteJsonAsync (dataObj : obj) = + member this.WriteJsonAsync<'T> (dataObj : 'T) = this.SetContentType "application/json" let serializer = this.GetJsonSerializer() - serializer.Serialize dataObj - |> this.WriteStringAsync + serializer.SerializeToBytes dataObj + |> this.WriteBytesAsync + + /// **Description** + /// + /// Serializes an object to JSON and writes the output to the body of the HTTP response using chunked transfer encoding. + /// + /// It also sets the HTTP `Content-Type` header to `application/json` and sets the `Transfer-Encoding` header to `chunked`. + /// + /// The JSON serializer can be configured in the ASP.NET Core startup code by registering a custom class of type `IJsonSerializer`. + /// + /// **Parameters** + /// + /// - `dataObj`: The object to be send back to the client. + /// + /// **Output** + /// + /// Task of `Some HttpContext` after writing to the body of the response. + /// + member this.WriteJsonChunkedAsync<'T> (dataObj : 'T) = + task { + this.SetContentType "application/json" + this.SetHttpHeader "Transfer-Encoding" "chunked" + let serializer = this.GetJsonSerializer() + do! serializer.SerializeToStreamAsync dataObj this.Response.Body + return Some this + } /// **Description** /// @@ -167,8 +193,15 @@ type HttpContext with /// Task of `Some HttpContext` after writing to the body of the response. /// member this.WriteHtmlViewAsync (htmlView : XmlNode) = + let sb = new StringBuilder() + ViewBuilder.buildHtmlDocument sb htmlView |> ignore + let chars = ArrayPool.Shared.Rent(sb.Length) + sb.CopyTo(0, chars, 0, sb.Length) + let result = Encoding.UTF8.GetBytes(chars, 0, sb.Length) + ArrayPool.Shared.Return chars + this.SetContentType "text/html" - this.WriteStringAsync (renderHtmlDocument htmlView) + this.WriteBytesAsync result // --------------------------- // HttpHandler functions @@ -238,10 +271,30 @@ let text (str : string) : HttpHandler = /// /// A Giraffe `HttpHandler` function which can be composed into a bigger web application. /// -let json (dataObj : obj) : HttpHandler = +let json<'T> (dataObj : 'T) : HttpHandler = fun (next : HttpFunc) (ctx : HttpContext) -> ctx.WriteJsonAsync dataObj +/// **Description** +/// +/// Serializes an object to JSON and writes the output to the body of the HTTP response using chunked transfer encoding. +/// +/// It also sets the HTTP `Content-Type` header to `application/json` and sets the `Transfer-Encoding` header to `chunked`. +/// +/// The JSON serializer can be configured in the ASP.NET Core startup code by registering a custom class of type `IJsonSerializer`. +/// +/// **Parameters** +/// +/// - `dataObj`: The object to be send back to the client. +/// +/// **Output** +/// +/// A Giraffe `HttpHandler` function which can be composed into a bigger web application. +/// +let jsonChunked<'T> (dataObj : 'T) : HttpHandler = + fun (next : HttpFunc) (ctx : HttpContext) -> + ctx.WriteJsonChunkedAsync dataObj + /// **Description** /// /// Serializes an object to XML and writes the output to the body of the HTTP response. diff --git a/src/Giraffe/Serialization.fs b/src/Giraffe/Serialization.fs index 9703cc23..8a340905 100644 --- a/src/Giraffe/Serialization.fs +++ b/src/Giraffe/Serialization.fs @@ -6,11 +6,13 @@ namespace Giraffe.Serialization [] module Json = + open System open System.IO + open System.Text open System.Threading.Tasks open Newtonsoft.Json open Newtonsoft.Json.Serialization - open FSharp.Control.Tasks.V2.ContextInsensitive + open Utf8Json /// **Description** /// @@ -18,40 +20,96 @@ module Json = /// [] type IJsonSerializer = - abstract member Serialize : obj -> string + abstract member SerializeToString<'T> : 'T -> string + abstract member SerializeToBytes<'T> : 'T -> byte array + abstract member SerializeToStreamAsync<'T> : 'T -> Stream -> Task + abstract member Deserialize<'T> : string -> 'T - abstract member Deserialize<'T> : Stream -> 'T + abstract member Deserialize<'T> : byte[] -> 'T abstract member DeserializeAsync<'T> : Stream -> Task<'T> /// **Description** /// - /// Default JSON serializer in Giraffe. + /// The `Utf8JsonSerializer` is the default `IJsonSerializer` in Giraffe. + /// + /// It uses `Utf8Json` as the underlying JSON serializer to (de-)serialize + /// JSON content. [Utf8Json](https://github.com/neuecc/Utf8Json) is currently + /// the fastest JSON serializer for .NET. + /// + type Utf8JsonSerializer (resolver : IJsonFormatterResolver) = + + static member DefaultResolver = Utf8Json.Resolvers.StandardResolver.CamelCase + + interface IJsonSerializer with + member __.SerializeToString (x : 'T) = + JsonSerializer.ToJsonString (x, resolver) + + member __.SerializeToBytes (x : 'T) = + JsonSerializer.Serialize (x, resolver) + + member __.SerializeToStreamAsync (x : 'T) (stream : Stream) = + JsonSerializer.SerializeAsync(stream, x, resolver) + + member __.Deserialize<'T> (json : string) : 'T = + let bytes = Encoding.UTF8.GetBytes json + JsonSerializer.Deserialize(bytes, resolver) + + member __.Deserialize<'T> (bytes : byte array) : 'T = + JsonSerializer.Deserialize(bytes, resolver) + + member __.DeserializeAsync<'T> (stream : Stream) : Task<'T> = + JsonSerializer.DeserializeAsync(stream, resolver) + + /// **Description** + /// + /// The previous default JSON serializer in Giraffe. + /// + /// The `NewtonsoftJsonSerializer` has been replaced by `Utf8JsonSerializer` as + /// the default `IJsonSerializer` which has much better performance and supports + /// true chunked transfer encoding. + /// + /// The `NewtonsoftJsonSerializer` remains available as an alternative JSON + /// serializer which can be used to override the `Utf8JsonSerializer` for + /// backwards compatibility. /// /// Serializes objects to camel cased JSON code. /// type NewtonsoftJsonSerializer (settings : JsonSerializerSettings) = + + let Utf8EncodingWithoutBom = new UTF8Encoding(false) + let DefaultBufferSize = 1024 + static member DefaultSettings = JsonSerializerSettings( ContractResolver = CamelCasePropertyNamesContractResolver()) interface IJsonSerializer with - member __.Serialize (o : obj) = JsonConvert.SerializeObject(o, settings) + member __.SerializeToString (x : 'T) = + JsonConvert.SerializeObject(x, settings) - member __.Deserialize<'T> (json : string) = JsonConvert.DeserializeObject<'T>(json, settings) + member __.SerializeToBytes (x : 'T) = + JsonConvert.SerializeObject(x, settings) + |> Encoding.UTF8.GetBytes - member __.Deserialize<'T> (stream : Stream) = - use sr = new StreamReader(stream, true) - use jr = new JsonTextReader(sr) + member __.SerializeToStreamAsync (x : 'T) (stream : Stream) = + use sw = new StreamWriter(stream, Utf8EncodingWithoutBom, DefaultBufferSize, true) + use jw = new JsonTextWriter(sw) let sr = JsonSerializer.Create settings - sr.Deserialize<'T> jr + sr.Serialize(jw, x) + Task.CompletedTask + + 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 sr = new StreamReader(stream, true) - use jr = new JsonTextReader(sr) - let sr = JsonSerializer.Create settings - return sr.Deserialize<'T> jr - } + use sr = new StreamReader(stream, true) + use jr = new JsonTextReader(sr) + let sr = JsonSerializer.Create settings + Task.FromResult(sr.Deserialize<'T> jr) // --------------------------- // XML diff --git a/tests/Giraffe.Benchmarks/Giraffe.Benchmarks.fsproj b/tests/Giraffe.Benchmarks/Giraffe.Benchmarks.fsproj new file mode 100644 index 00000000..ecc4376e --- /dev/null +++ b/tests/Giraffe.Benchmarks/Giraffe.Benchmarks.fsproj @@ -0,0 +1,23 @@ + + + + Exe + netcoreapp2.0;netcoreapp2.1 + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Giraffe.Benchmarks/Program.fs b/tests/Giraffe.Benchmarks/Program.fs new file mode 100644 index 00000000..3ffa3916 --- /dev/null +++ b/tests/Giraffe.Benchmarks/Program.fs @@ -0,0 +1,73 @@ +open BenchmarkDotNet.Attributes; +open BenchmarkDotNet.Running; +open Giraffe.GiraffeViewEngine +open System.Text +open System.Buffers + +[] +type HtmlUtf8Benchmark() = + + let doc = + div [] [ + div [ _class "top-bar" ] + [ div [ _class "top-bar-left" ] + [ ul [ _class "dropdown menu" + _data "dropdown-menu" ] + [ li [ _class "menu-text" ] + [ rawText "Site Title" ] + li [ ] + [ a [ _href "#" ] + [ encodedText """One """ ] + ul [ _class "menu vertical" ] + [ li [ ] + [ a [ _href "#" ] + [ rawText "One" ] ] + li [ ] + [ a [ _href "#" ] + [ encodedText "Two" ] ] + li [ ] + [ a [ _href "#" ] + [ rawText "Three" ] ] ] ] + li [ ] + [ a [ _href "#" ] + [ encodedText "Two" ] ] + li [ ] + [ a [ _href "#" ] + [ encodedText "Three" ] ] ] ] + div [ _class "top-bar-right" ] + [ ul [ _class "menu" ] + [ li [ ] + [ input [ _type "search" + _placeholder "Search" ] ] + li [ ] + [ button [ _type "button" + _class "button" ] + [ rawText "Search" ] ] ] ] ] + ] + + let stringBuilder = new StringBuilder(16 * 1024) + + [] + member this.Default() = + renderHtmlDocument doc |> Encoding.UTF8.GetBytes + + [] + member this.CachedStringBuilder() = + ViewBuilder.buildHtmlDocument stringBuilder doc + stringBuilder.ToString() |> Encoding.UTF8.GetBytes |> ignore + stringBuilder.Clear(); + + [] + member this.CachedStringBuilderPooledUtf8Array() = + ViewBuilder.buildHtmlDocument stringBuilder doc + let chars = ArrayPool.Shared.Rent(stringBuilder.Length) + stringBuilder.CopyTo(0, chars, 0, stringBuilder.Length) + Encoding.UTF8.GetBytes(chars, 0, stringBuilder.Length) |> ignore + ArrayPool.Shared.Return(chars) + stringBuilder.Clear() + +[] +let main args = + let asm = typeof.Assembly + BenchmarkSwitcher.FromAssembly(asm).Run(args) |> ignore + 0 \ No newline at end of file diff --git a/tests/Giraffe.Tests/FormatExpressionTests.fs b/tests/Giraffe.Tests/FormatExpressionTests.fs index 0b71a38a..39242dc9 100644 --- a/tests/Giraffe.Tests/FormatExpressionTests.fs +++ b/tests/Giraffe.Tests/FormatExpressionTests.fs @@ -200,6 +200,20 @@ let ``Format string with single "%O" matches "FE9CFE1935D44EDC9A955D38C4D579BD"` | None -> assertFail "Format failed to match input." | Some g -> Assert.Equal(Guid("FE9CFE19-35D4-4EDC-9A95-5D38C4D579BD"), g) +[] +let ``Format string with single "%O" matches "Xy0MVKupFES9NpmZ9TiHcw"`` () = + tryMatchInput "%O" "Xy0MVKupFES9NpmZ9TiHcw" false + |> function + | None -> assertFail "Format failed to match input." + | Some g -> Assert.Equal(Guid("540c2d5f-a9ab-4414-bd36-9999f5388773"), g) + +[] +let ``Format string with single "%u" matches "FOwfPLe6waQ"`` () = + tryMatchInput "%u" "FOwfPLe6waQ" false + |> function + | None -> assertFail "Format failed to match input." + | Some id -> Assert.Equal(1507614320903242148UL, id) + [] let ``Format string with "%s" matches url encoded string`` () = tryMatchInput "/encode/%s" "/encode/a%2fb%2Bc.d%2Ce" false diff --git a/tests/Giraffe.Tests/Giraffe.Tests.fsproj b/tests/Giraffe.Tests/Giraffe.Tests.fsproj index 040a7e5d..27e693ea 100644 --- a/tests/Giraffe.Tests/Giraffe.Tests.fsproj +++ b/tests/Giraffe.Tests/Giraffe.Tests.fsproj @@ -3,11 +3,12 @@ net461;netcoreapp2.1 Giraffe.Tests - false + portable + diff --git a/tests/Giraffe.Tests/GiraffeViewEngineTests.fs b/tests/Giraffe.Tests/GiraffeViewEngineTests.fs index 89f0340e..bcbe2e4c 100644 --- a/tests/Giraffe.Tests/GiraffeViewEngineTests.fs +++ b/tests/Giraffe.Tests/GiraffeViewEngineTests.fs @@ -33,9 +33,9 @@ let ``Nested content should render correctly`` () = comment "this is a test" h1 [] [ encodedText "Header" ] p [] [ - EncodedText "Lorem " + rawText "Lorem " strong [] [ encodedText "Ipsum" ] - RawText " dollar" + encodedText " dollar" ] ] let html = nested @@ -51,4 +51,4 @@ let ``Void tag in XML should be self closing tag`` () = [] let ``Void tag in HTML should be unary tag`` () = let unary = br [] |> renderHtmlNode - Assert.Equal("
", unary) \ No newline at end of file + Assert.Equal("
", unary) diff --git a/tests/Giraffe.Tests/GuidAndIdTests.fs b/tests/Giraffe.Tests/GuidAndIdTests.fs new file mode 100644 index 00000000..ae5bad8d --- /dev/null +++ b/tests/Giraffe.Tests/GuidAndIdTests.fs @@ -0,0 +1,113 @@ +module Giraffe.Tests.ShortGuidTests + +open System +open Xunit +open Giraffe.Common + +// --------------------------------- +// Short Guid Tests +// --------------------------------- + +let rndInt64 (rand : Random) = + let buffer = Array.zeroCreate 8 + rand.NextBytes buffer + BitConverter.ToUInt64(buffer, 0) + +[] +let ``Short Guids translate to correct long Guids`` () = + let testCases = + [ + "FEx1sZbSD0ugmgMAF_RGHw", Guid "b1754c14-d296-4b0f-a09a-030017f4461f" + "Xy0MVKupFES9NpmZ9TiHcw", Guid "540c2d5f-a9ab-4414-bd36-9999f5388773" + ] + + testCases + |> List.iter (fun (shortGuid, expectedGuid) -> + let guid = ShortGuid.toGuid shortGuid + Assert.Equal(expectedGuid, guid) + |> ignore) + +[] +let ``Long Guids translate to correct short Guids`` () = + let testCases = + [ + "FEx1sZbSD0ugmgMAF_RGHw", Guid "b1754c14-d296-4b0f-a09a-030017f4461f" + "Xy0MVKupFES9NpmZ9TiHcw", Guid "540c2d5f-a9ab-4414-bd36-9999f5388773" + ] + + testCases + |> List.iter (fun (shortGuid, longGuid) -> + let guid = ShortGuid.fromGuid longGuid + Assert.Equal(shortGuid, guid) + |> ignore) + +[] +let ``Short Guids are always 22 characters long`` () = + let testCases = + [ 0..10 ] + |> List.map (fun _ -> Guid.NewGuid()) + + testCases + |> List.iter (fun guid -> + let shortGuid = ShortGuid.fromGuid guid + Assert.Equal(22, shortGuid.Length) + |> ignore) + +[] +let ``Short Ids are always 11 characters long`` () = + let rand = new Random() + let testCases = + [ 0..10 ] + |> List.map (fun _ -> rndInt64 rand) + + testCases + |> List.iter (fun id -> + let shortId = ShortId.fromUInt64 id + Assert.Equal(11, shortId.Length) + |> ignore) + +[] +let ``Short Ids translate correctly back and forth`` () = + let rand = new Random() + let testCases = + [ 0..10 ] + |> List.map (fun _ -> rndInt64 rand) + + testCases + |> List.iter (fun origId -> + let shortId = ShortId.fromUInt64 origId + let id = ShortId.toUInt64 shortId + Assert.Equal(origId, id) + |> ignore) + +[] +let ``Short Ids translate to correct uint64 values`` () = + let testCases = + [ + "r1iKapqh_s4", 12635000945053400782UL + "5aLu720NzTs", 16547050693006839099UL + "BdQ5vc0d8-I", 420024152605193186UL + "FOwfPLe6waQ", 1507614320903242148UL + ] + + testCases + |> List.iter (fun (shortId, id) -> + let result = ShortId.toUInt64 shortId + Assert.Equal(id, result) + |> ignore) + +[] +let ``UInt64 values translate to correct short IDs`` () = + let testCases = + [ + "r1iKapqh_s4", 12635000945053400782UL + "5aLu720NzTs", 16547050693006839099UL + "BdQ5vc0d8-I", 420024152605193186UL + "FOwfPLe6waQ", 1507614320903242148UL + ] + + testCases + |> List.iter (fun (shortId, id) -> + let result = ShortId.fromUInt64 id + Assert.Equal(shortId, result) + |> ignore) \ No newline at end of file diff --git a/tests/Giraffe.Tests/Helpers.fs b/tests/Giraffe.Tests/Helpers.fs index 4ede18a4..d1f171d5 100644 --- a/tests/Giraffe.Tests/Helpers.fs +++ b/tests/Giraffe.Tests/Helpers.fs @@ -19,11 +19,22 @@ open NSubstitute open Newtonsoft.Json open Giraffe open Giraffe.Serialization +open Utf8Json // --------------------------------- // Common functions // --------------------------------- +let toTheoryData xs = + let data = new TheoryData<_>() + for x in xs do data.Add x + data + +let toTheoryData2 xs = + let data = new TheoryData<_,_>() + for (a,b) in xs do data.Add(a,b) + data + let waitForDebuggerToAttach() = printfn "Waiting for debugger to attach." printfn "Press enter when debugger is attached in order to continue test execution..." @@ -97,13 +108,45 @@ let createHost (configureApp : 'Tuple -> IApplicationBuilder -> unit) .Configure(Action (configureApp args)) .ConfigureServices(Action configureServices) -let mockJson (ctx : HttpContext) (settings : JsonSerializerSettings option) = - let jsonSettings = - defaultArg settings NewtonsoftJsonSerializer.DefaultSettings - ctx.RequestServices - .GetService(typeof) - .Returns(NewtonsoftJsonSerializer(jsonSettings)) - |> ignore +type MockJsonSettings = + | Newtonsoft of JsonSerializerSettings option + | Utf8 of IJsonFormatterResolver option + +let mockJson (ctx : HttpContext) (settings : MockJsonSettings) = + + match settings with + | Newtonsoft settings -> + let jsonSettings = + defaultArg settings NewtonsoftJsonSerializer.DefaultSettings + ctx.RequestServices + .GetService(typeof) + .Returns(new NewtonsoftJsonSerializer(jsonSettings)) + |> ignore + + | Utf8 settings -> + let resolver = + defaultArg settings Utf8JsonSerializer.DefaultResolver + ctx.RequestServices + .GetService(typeof) + .Returns(new Utf8JsonSerializer(resolver)) + |> ignore + +type JsonSerializersData = + + static member DefaultSettings = [ + Utf8 None; + Newtonsoft None + ] + + static member DefaultData = JsonSerializersData.DefaultSettings |> toTheoryData + + static member PreserveCaseSettings = + [ + Utf8 (Some Utf8Json.Resolvers.StandardResolver.Default) + Newtonsoft (Some (new JsonSerializerSettings())) + ] + + static member PreserveCaseData = JsonSerializersData.PreserveCaseSettings |> toTheoryData let mockXml (ctx : HttpContext) = ctx.RequestServices diff --git a/tests/Giraffe.Tests/HttpContextExtensionsTests.fs b/tests/Giraffe.Tests/HttpContextExtensionsTests.fs index 2e5418f6..4619a0fc 100644 --- a/tests/Giraffe.Tests/HttpContextExtensionsTests.fs +++ b/tests/Giraffe.Tests/HttpContextExtensionsTests.fs @@ -85,7 +85,7 @@ let ``WriteHtmlViewAsync should add html to the context`` () = html [] [ head [] [] body [] [ - h1 [] [ EncodedText "Hello world" ] + h1 [] [ Text "Hello world" ] ] ] ctx.WriteHtmlViewAsync(htmlDoc) diff --git a/tests/Giraffe.Tests/HttpHandlerTests.fs b/tests/Giraffe.Tests/HttpHandlerTests.fs index 29c670da..714fc55e 100644 --- a/tests/Giraffe.Tests/HttpHandlerTests.fs +++ b/tests/Giraffe.Tests/HttpHandlerTests.fs @@ -44,10 +44,11 @@ type Person = // Tests // --------------------------------- -[] -let ``GET "/json" returns json object`` () = +[] +[)>] +let ``GET "/json" returns json object`` (settings) = let ctx = Substitute.For() - mockJson ctx None + mockJson ctx settings let app = GET >=> choose [ route "/" >=> text "Hello World" @@ -68,11 +69,12 @@ let ``GET "/json" returns json object`` () = | Some ctx -> Assert.Equal(expected, getBody ctx) } -[] -let ``GET "/json" with custom json settings returns json object`` () = - let settings = Newtonsoft.Json.JsonSerializerSettings() +[] +[)>] +let ``GET "/json" with custom json settings returns json object`` (settings) = + let ctx = Substitute.For() - mockJson ctx (Some settings) + mockJson ctx settings let app = GET >=> choose [ route "/" >=> text "Hello World" @@ -93,6 +95,80 @@ let ``GET "/json" with custom json settings returns json object`` () = | Some ctx -> Assert.Equal(expected, getBody ctx) } +let DefaultMocksWithSize = + [ + let ``powers of two`` = [ 1..10 ] |> List.map (pown 2) + for size in ``powers of two`` do + for setting in JsonSerializersData.DefaultSettings do + yield size, setting + ] |> toTheoryData2 + +[] +[] +let ``GET "/jsonChunked" returns json object`` (size: int, settings) = + let ctx = Substitute.For() + mockJson ctx settings + let app = + GET >=> choose [ + route "/" >=> text "Hello World" + route "/foo" >=> text "bar" + route "/jsonChunked" >=> json ( Array.replicate size { Foo = "john"; Bar = "doe"; Age = 30 } ) + setStatusCode 404 >=> text "Not found" ] + + ctx.Request.Method.ReturnsForAnyArgs "GET" |> ignore + ctx.Request.Path.ReturnsForAnyArgs (PathString("/jsonChunked")) |> ignore + ctx.Response.Body <- new MemoryStream() + + let expected = + let o = "{\"foo\":\"john\",\"bar\":\"doe\",\"age\":30}" + let os = Array.replicate size o |> String.concat "," + "[" + os + "]" + + task { + let! result = app next ctx + + match result with + | None -> assertFailf "Result was expected to be %s" expected + | Some ctx -> Assert.Equal(expected, getBody ctx) + } + +let CamelCasedMocksWithSize = + [ + let ``powers of two`` = [1..10] |> List.map (pown 2) + for size in ``powers of two`` do + for setting in JsonSerializersData.PreserveCaseSettings do + yield size ,setting + ] |> toTheoryData2 + +[] +[] +let ``GET "/jsonChunked" with custom json settings returns json object`` (size: int, settings) = + let ctx = Substitute.For() + mockJson ctx settings + let app = + GET >=> choose [ + route "/" >=> text "Hello World" + route "/foo" >=> text "bar" + route "/jsonChunked" >=> json ( Array.replicate size { Foo = "john"; Bar = "doe"; Age = 30 } ) + setStatusCode 404 >=> text "Not found" ] + + ctx.Request.Method.ReturnsForAnyArgs "GET" |> ignore + ctx.Request.Path.ReturnsForAnyArgs (PathString("/jsonChunked")) |> ignore + ctx.Response.Body <- new MemoryStream() + + let expected = + let o = "{\"Foo\":\"john\",\"Bar\":\"doe\",\"Age\":30}" + let os = Array.replicate size o |> String.concat "," + "[" + os + "]" + + task { + let! result = app next ctx + + match result with + | None -> assertFailf "Result was expected to be %s" expected + | Some ctx -> Assert.Equal(expected, getBody ctx) + } + [] let ``POST "/post/1" returns "1"`` () = let ctx = Substitute.For() @@ -210,7 +286,7 @@ let ``POST "/text" with supported Accept header returns "text"`` () = [] let ``POST "/json" with supported Accept header returns "json"`` () = let ctx = Substitute.For() - mockJson ctx None + mockJson ctx ( Newtonsoft None ) let app = choose [ GET >=> choose [ @@ -310,6 +386,7 @@ let ``POST "/either" with unsupported Accept header returns 404 "Not found"`` () [] let ``GET "/person" returns rendered HTML view`` () = let ctx = Substitute.For() + let personView model = html [] [ head [] [ @@ -359,7 +436,7 @@ let ``Get "/auto" with Accept header of "application/json" returns JSON object`` } let ctx = Substitute.For() - mockJson ctx None + mockJson ctx (Newtonsoft None) mockNegotiation ctx let app = GET >=> choose [ @@ -401,7 +478,7 @@ let ``Get "/auto" with Accept header of "application/xml; q=0.9, application/jso } let ctx = Substitute.For() - mockJson ctx None + mockJson ctx (Newtonsoft None) mockNegotiation ctx let app = GET >=> choose [ @@ -547,7 +624,7 @@ let ``Get "/auto" with Accept header of "application/json, application/xml" retu } let ctx = Substitute.For() - mockJson ctx None + mockJson ctx (Newtonsoft None) mockNegotiation ctx let app = GET >=> choose [ @@ -780,7 +857,7 @@ let ``Get "/auto" without an Accept header returns a JSON object`` () = } let ctx = Substitute.For() - mockJson ctx None + mockJson ctx (Newtonsoft None) mockNegotiation ctx let app = GET >=> choose [ diff --git a/tests/Giraffe.Tests/ModelBindingTests.fs b/tests/Giraffe.Tests/ModelBindingTests.fs index c57211e2..2b1ae4a7 100644 --- a/tests/Giraffe.Tests/ModelBindingTests.fs +++ b/tests/Giraffe.Tests/ModelBindingTests.fs @@ -547,10 +547,11 @@ let ``tryBindQuery with complete data but baldy formated list items`` () = // HttpContext Model Binding Tests // --------------------------------- -[] -let ``BindJsonAsync test`` () = +[] +[)>] +let ``BindJsonAsync test`` (settings) = let ctx = Substitute.For() - mockJson ctx None + mockJson ctx settings let outputCustomer (c : Customer) = text (c.ToString()) let app = @@ -769,10 +770,11 @@ let ``BindQueryString with nullable property test`` () = return! testRoute "?NullableInt=&NullableDateTime=" { NullableInt = Nullable<_>(); NullableDateTime = Nullable<_>() } } -[] -let ``BindModelAsync with JSON content returns correct result`` () = +[] +[)>] +let ``BindModelAsync with JSON content returns correct result`` (settings) = let ctx = Substitute.For() - mockJson ctx None + mockJson ctx settings let outputCustomer (c : Customer) = text (c.ToString()) let app = @@ -807,10 +809,11 @@ let ``BindModelAsync with JSON content returns correct result`` () = | Some ctx -> Assert.Equal(expected, getBody ctx) } -[] -let ``BindModelAsync with JSON content that uses custom serialization settings returns correct result`` () = +[] +[)>] +let ``BindModelAsync with JSON content that uses custom serialization settings returns correct result`` (settings) = let ctx = Substitute.For() - mockJson ctx (Some (JsonSerializerSettings())) + mockJson ctx settings let outputCustomer (c : Customer) = text (c.ToString()) let app = @@ -962,10 +965,11 @@ let ``BindModelAsync with culture aware form content returns correct result`` () | Some ctx -> Assert.Equal(expected, getBody ctx) } -[] -let ``BindModelAsync with JSON content and a specific charset returns correct result`` () = +[] +[)>] +let ``BindModelAsync with JSON content and a specific charset returns correct result`` (settings) = let ctx = Substitute.For() - mockJson ctx None + mockJson ctx settings let outputCustomer (c : Customer) = text (c.ToString()) let app = diff --git a/tests/Giraffe.Tests/RoutingTests.fs b/tests/Giraffe.Tests/RoutingTests.fs index f7ea0a09..5e691232 100644 --- a/tests/Giraffe.Tests/RoutingTests.fs +++ b/tests/Giraffe.Tests/RoutingTests.fs @@ -89,7 +89,7 @@ let ``route: GET "/FOO" returns 404 "Not found"`` () = [] let ``GET "/JSON" returns "BaR"`` () = let ctx = Substitute.For() - mockJson ctx None + mockJson ctx (Newtonsoft None) let app = GET >=> choose [ route "/" >=> text "Hello World" @@ -363,7 +363,7 @@ let ``routef: GET "/foo/%O/bar/%O" returns "Guid1: ..., Guid2: ..."`` () = route "/foo" >=> text "bar" routef "/foo/%s/bar" text routef "/foo/%s/%i" (fun (name, age) -> text (sprintf "Name: %s, Age: %d" name age)) - routef "/foo/%O/bar/%O" (fun (guid1: Guid, guid2: Guid) -> text (sprintf "Guid1: %O, Guid2: %O" guid1 guid2)) + routef "/foo/%O/bar/%O" (fun (guid1 : Guid, guid2 : Guid) -> text (sprintf "Guid1: %O, Guid2: %O" guid1 guid2)) setStatusCode 404 >=> text "Not found" ] ctx.Request.Method.ReturnsForAnyArgs "GET" |> ignore @@ -379,6 +379,31 @@ let ``routef: GET "/foo/%O/bar/%O" returns "Guid1: ..., Guid2: ..."`` () = | Some ctx -> Assert.Equal(expected, getBody ctx) } +[] +let ``routef: GET "/foo/%u/bar/%u" returns "Id1: ..., Id2: ..."`` () = + let ctx = Substitute.For() + let app = + GET >=> choose [ + route "/" >=> text "Hello World" + route "/foo" >=> text "bar" + routef "/foo/%s/bar" text + routef "/foo/%s/%i" (fun (name, age) -> text (sprintf "Name: %s, Age: %d" name age)) + routef "/foo/%u/bar/%u" (fun (id1 : uint64, id2 : uint64) -> text (sprintf "Id1: %u, Id2: %u" id1 id2)) + setStatusCode 404 >=> text "Not found" ] + + ctx.Request.Method.ReturnsForAnyArgs "GET" |> ignore + ctx.Request.Path.ReturnsForAnyArgs (PathString("/foo/r1iKapqh_s4/bar/5aLu720NzTs")) |> ignore + ctx.Response.Body <- new MemoryStream() + let expected = "Id1: 12635000945053400782, Id2: 16547050693006839099" + + task { + let! result = app next ctx + + match result with + | None -> assertFailf "Result was expected to be %s" expected + | Some ctx -> Assert.Equal(expected, getBody ctx) + } + // --------------------------------- // routeCif Tests // --------------------------------- @@ -386,7 +411,7 @@ let ``routef: GET "/foo/%O/bar/%O" returns "Guid1: ..., Guid2: ..."`` () = [] let ``POST "/POsT/1" returns "1"`` () = let ctx = Substitute.For() - mockJson ctx None + mockJson ctx (Newtonsoft None) let app = choose [ GET >=> choose [ @@ -412,7 +437,7 @@ let ``POST "/POsT/1" returns "1"`` () = [] let ``POST "/POsT/523" returns "523"`` () = let ctx = Substitute.For() - mockJson ctx None + mockJson ctx (Newtonsoft None) let app = choose [ GET >=> choose [