Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Cache-Control for static files #271 #383

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------

FROM mcr.microsoft.com/dotnet/sdk:5.0
FROM mcr.microsoft.com/dotnet/sdk:6.0

# Avoid warnings by switching to noninteractive
ENV DEBIAN_FRONTEND=noninteractive
Expand Down
22 changes: 12 additions & 10 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
// See https://aka.ms/vscode-remote/devcontainer.json for format details.
{
"name": "F# (.NET Core 5.0 Latest)",
"name": "F# (.NET Core 6.0 Latest)",
"dockerFile": "Dockerfile",
// Uncomment the next line if you want to publish any ports.
// "appPort": [],
// Uncomment the next line to run commands after the container is created.
"postCreateCommand": "dotnet tool restore",
"extensions": [
"ionide.ionide-fsharp",
"ms-dotnettools.csharp"
],
"settings": {
"FSharp.fsacRuntime": "netcore",
"FSharp.useSdkScripts": true,
},
"remoteUser": "vscode"
"customizations": {
"vscode": {
"extensions": [
],
"settings": {
"FSharp.fsacRuntime": "netcore",
"FSharp.useSdkScripts": true
}
}
},
"remoteUser": "vscode"
}
62 changes: 52 additions & 10 deletions src/Saturn/Application.fs
Original file line number Diff line number Diff line change
Expand Up @@ -355,22 +355,64 @@ module Application =

///Enables using static file hosting.
[<CustomOperation("use_static")>]
member __.UseStatic(state, path : string) =
let middleware (app : IApplicationBuilder) =
match app.UseDefaultFiles(), state.MimeTypes with
|app, [] -> app.UseStaticFiles()
|app, mimes ->
let provider = FileExtensionContentTypeProvider()
mimes |> List.iter (fun (extension, mime) -> provider.Mappings.[extension] <- mime)
app.UseStaticFiles(StaticFileOptions(ContentTypeProvider=provider))
member __.UseStatic(state: ApplicationState, path: string, ?cacheControls: Saturn.CacheControls.CacheControl list) =
/// what should the type of cacheControls be ?
/// string
/// an instance of Microsoft.Net.Http.Headers.CacheControlHeaderValue
/// or fsharp union type as here
/// some other option

let middleware (app:IApplicationBuilder) =
//server files
let fileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), path))
let requestPath: PathString = PathString("/" + path)

//add mine types
let fileExtensionProvider =
match state.MimeTypes with
| [] -> None
| mines ->
let fileExtensionContentTypeProvider = FileExtensionContentTypeProvider()
mines |> List.iter (fun (extension, mime) -> fileExtensionContentTypeProvider.Mappings.[extension] <- mime)
Some fileExtensionContentTypeProvider

let onPrepareResponse cacheControlHeaders =
//add cache values
let handler cacheControlHeaders (ctx : StaticFileResponseContext) =
let logger = ctx.Context.GetLogger()
let values = cacheControlHeaders |> CacheControls.generate
ctx.Context.Response.Headers.Append(Microsoft.Net.Http.Headers.HeaderNames.CacheControl, values)
logger.LogInformation $"Cache Header {values} was added to response"

System.Action<StaticFileResponseContext>(cacheControlHeaders |> handler)


//use a suitable options
let options =
match fileExtensionProvider, cacheControls with
| Some provider, Some cacheControlHeaders ->
StaticFileOptions(ContentTypeProvider = provider, OnPrepareResponse = (cacheControlHeaders |> onPrepareResponse) , FileProvider = fileProvider, RequestPath = requestPath)
| Some provider, None ->
StaticFileOptions(ContentTypeProvider = provider, RequestPath = requestPath)
| None, Some cacheControlHeaders ->
StaticFileOptions(OnPrepareResponse = (cacheControlHeaders |> onPrepareResponse), FileProvider = fileProvider, RequestPath = requestPath)
| None, None ->
StaticFileOptions(FileProvider = fileProvider, RequestPath = requestPath)

app.UseStaticFiles options

let host (builder: IWebHostBuilder) =
let p = Path.Combine(Directory.GetCurrentDirectory(), path)
let p = Path.Combine(Directory.GetCurrentDirectory(), path)
builder
.UseWebRoot(p)

{ state with
AppConfigs = middleware::state.AppConfigs
WebHostConfigs = host::state.WebHostConfigs
}
}

[<CustomOperation("use_static")>]
member __.UseStatic(state, path : string) = __.UseStatic(state, path)

[<CustomOperation("use_config")>]
member __.UseConfig(state : ApplicationState, configBuilder : IConfiguration -> 'a) =
Expand Down
43 changes: 43 additions & 0 deletions src/Saturn/CacheControlHelper.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace Saturn

[<AutoOpen>]
module CacheControls =

/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
/// response header values for cache-control
type CacheControl =
| NoCache
| NoStore
| NoTransform
| StaleIfError
| Immutable
| MaxAge of int
| MustRevalidate
| MustUnderstand
| Private
| ProxyRevalidate
| Public
| SMaxage of int
| StaleWhileRevalidate

let generate (cacheControls: CacheControl list) =

let mapper = function
| NoCache -> "no-cache"
| NoStore -> "no-store"
| NoTransform -> "no-transform"
| StaleIfError -> "stale-if-error"
| Immutable -> "immutable"
| MaxAge age-> $"max-age={age}"
| MustRevalidate -> "must-revalidate"
| MustUnderstand -> "must-understand"
| Private -> "private"
| ProxyRevalidate -> "proxy-revalidate"
| Public -> "public"
| SMaxage age-> $"s-maxage={age}"
| StaleWhileRevalidate -> "stale-while-revalidate"

cacheControls
|> List.map mapper
|> List.toArray
|> Microsoft.Extensions.Primitives.StringValues
7 changes: 3 additions & 4 deletions src/Saturn/Saturn.fsproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Saturn</AssemblyName>
Expand All @@ -21,15 +21,14 @@
<Compile Include="ControllerHelpers.fs" />
<Compile Include="Controller.fs" />
<Compile Include="ControllerEndpoint.fs" />
<Compile Include="CacheControlHelper.fs" />
<Compile Include="Authentication.fs" />
<Compile Include="Channels.fs" />
<Compile Include="Application.fs" />
<Compile Include="Links.fs" />
</ItemGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<Import Project="..\..\.paket\Paket.Restore.targets" />
</Project>
</Project>
54 changes: 54 additions & 0 deletions tests/Saturn.UnitTests/CacheTests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/// <summary>Test response cache values sent to client</summary>
module CacheTests

open Giraffe
open Saturn.Endpoint
open Saturn.CacheControls
open System.Collections.Generic
open System


//---------------------------`Response.accepted` tests----------------------------------------
let testCiRouter = router {
get "/static/index.html" (htmlFile"/static/index.html")
}

// //https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
let nonErrorStatusCodes = seq { 100..299 }

[<Expecto.Tests>]
let tests =
Expecto.Tests.testList "use_static Response.accepted cache tests" [
Expecto.Tests.testCase "Correct status code not overloaded" <| fun _ ->
let emptyContext = getEmptyContext "GET" "/static/index.html"
let maxAge = 3600

let cacheValues = [ ]
let context = testHostWithContext (hostFromControllerCached testCiRouter cacheValues) emptyContext

let body = getBody' context
Expecto.Expect.stringStarts body "<h1>Hello from static</h1>" "Should be a cached"

let headers =
Dictionary(context.Response.Headers, StringComparer.OrdinalIgnoreCase)

Expecto.Expect.isFalse (headers.ContainsKey "cache-control") "Cache header not as expected"
Expecto.Expect.contains nonErrorStatusCodes context.Response.StatusCode "Should be a cachable code status"

Expecto.Tests.testCase "Correct status code overloaded" <| fun _ ->
let emptyContext = getEmptyContext "GET" "/static/index.html"
let maxAge = 3600
let cacheValues = [ CacheControl.Public; CacheControl.MaxAge 3600 ]
let context = testHostWithContext (hostFromControllerCached testCiRouter cacheValues) emptyContext

let body = getBody' context
Expecto.Expect.stringStarts body "<h1>Hello from static</h1>" "Should be a cached"

let headers =
Dictionary(context.Response.Headers, StringComparer.OrdinalIgnoreCase)

Expecto.Expect.isTrue (headers.ContainsKey "cache-control") "Cache header not as expected"
Expecto.Expect.containsAll (headers["cache-control"]) [|"public"; $"max-age={maxAge}"|] "Cache header value not as expected"
Expecto.Expect.contains nonErrorStatusCodes context.Response.StatusCode "Should be a cacheable code status"
]

10 changes: 10 additions & 0 deletions tests/Saturn.UnitTests/Helpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,13 @@ let hostFromController ctr =
service_config (fun sc -> sc.AddSingleton<IDependency>(dependency ()))
}
app.Build()

let hostFromControllerCached ctr cacheValues =
let app = application {
use_endpoint_router ctr
webhost_config (fun hs -> hs.UseTestServer ())
logging (fun lg -> lg.ClearProviders() |> ignore)
service_config (fun sc -> sc.AddSingleton<IDependency>(dependency ()))
use_static "static" cacheValues
}
app.Build()
3 changes: 3 additions & 0 deletions tests/Saturn.UnitTests/Saturn.UnitTests.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<IsPackable>false</IsPackable>
<RunWorkingDirectory>$(MSBuildThisFileDirectory)</RunWorkingDirectory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Saturn\Saturn.fsproj">
<Name>Saturn.fsproj</Name>
</ProjectReference>
</ItemGroup>

<ItemGroup>
<Compile Include="Helpers.fs" />
<Compile Include="SimpleTests.fs" />
<Compile Include="CacheTests.fs" />
<Compile Include="ControllerTests.fs" />
<Compile Include="LinksTests.fs" />
<Compile Include="RouterTests.fs" />
Expand Down
25 changes: 25 additions & 0 deletions tests/Saturn.UnitTests/static/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<h1>Hello from static</h1>
<h2 id="clock"></h2>

<script>
if(window.fetch) {
fetch(location, {method:'HEAD'})
.then(function(r) {
console.log(Array.from(r.headers));
Array.from(r.headers)
.filter(item => item[0] == "etag" || item[0] == "cache-control" || item[0] == "last-modified")
.map(item => item[0] + "\n" + item[1] + "\n\n")
.forEach(function(item) {
document.body.appendChild(document.createElement("pre")).append(item);
});
});
}
else {
document.write("This does not work in your browser - no support for fetch API");
}

(function () {
var clockElement = document.getElementById( "clock" );
clock.innerHTML = "Last Page Refresh " + (new Date().toLocaleTimeString());
}());
</script>
Loading