George Danila's profile picture George Danila's Blog

Add caching for static resources in Saturn

Published on

Saturn is a great open source functional-first web framework that makes using ASP.NET Core more attractive to F# developers. If you’ve never used it before I highly recommend reading the docs and trying it out yourselves.

Saturn is built around Giraffe taking it’s core concept of HttpHandler to a whole new level, by introducing composable pipelines, routers, controllers configured using powerful computation expressions.

Let’s take a look at how a basic web app is configured in Saturn:

open Saturn
open Giraffe

let helloWorld = text "hello world"

let mainRouter = router {
    get "/" helloWorld
    not_found_handler (setStatusCode 404 >=> text "Oops, not found")
}

let app = application {
    use_router mainRouter
    url "http://0.0.0.0:8080"    
    use_static "public"    
}

run app

The application computation expression makes configuring the app very concise. As you can see, the standard use_static workflow operation can be configured with the name of the folder that is used to serve static files from, but no way of configuring caching for these static resources.

Let’s see how we can extend the Saturn ApplicationBuilder type with a new custom operation to enable this. (I will assume you’re familiar with F# going further):

module SaturnExtensions

open Saturn
open System
open System.IO
open Microsoft.Extensions.Primitives
open Microsoft.AspNetCore.Hosting
open Microsoft.AspNetCore.StaticFiles
open Microsoft.AspNetCore.Builder

type Saturn.Application.ApplicationBuilder with

    [<CustomOperation("use_static_with_cache")>]
    member _.UseStaticWithCache (state, path, (cacheMaxAge: CacheDuration)) =
        let middleware (app : IApplicationBuilder) =
            let options = StaticFileOptions()
            options.OnPrepareResponse <- fun ctx ->
                let h = ctx.Context.Response.Headers
                let hv = StringValues($"public, max-age={cacheMaxAge.Seconds}")
                h.["Cache-Control"] <- hv

            match state.MimeTypes with
            | [] -> ()
            | mimes ->
                let provider = 
                    FileExtensionContentTypeProvider()
                mimes 
                |> List.iter (fun (extension, mime) -> 
                    provider.Mappings.[extension] <- mime)

                options.ContentTypeProvider <- provider

            app.UseDefaultFiles()
               .UseStaticFiles(options)

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

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

We’ve added a new custom operation called use_static_with_cache that is very simmilar to the standard use_static but also enables the caller to provide a CacheDuration argument.

We make use of the standard UseStaticFiles middleware and we hook into the OnPrepareResponse callback to inject the Cache-Control HTTP response header required by caching to work. We also plug into the configuration pipeline in order to specify the web root for the given static resources path.

The cache duration is specified in seconds and this is computed by the Seconds member attached to the CacheDuration type, defined bellow:

type CacheDuration =
    | Days of int
    | Weeks of int
    | Months of int
    member this.Seconds =
        let now = DateTime.Now
        let diff x =
            let t = x - now
            t.TotalSeconds |> int
    
        match this with
        | Days v -> now.AddDays(float(v)) |> diff
        | Weeks v -> now.AddDays(float(v * 7)) |> diff
        | Months v -> now.AddMonths(v) |> diff

Using the new custom operation is easy. Simply replace the call to use_static with the new operation:

let app = application {
    use_router mainRouter
    url "http://0.0.0.0:8080"    
    use_static_with_cache "public" (Months 6)    
}

Make sure you open up the the SaturnExtensions module in order to have access to the type extension. Or mark the extensions module with the AutoOpen attribute.

Any static resource served from the public folder should now have the following HTTP response header attached: Cache-Control: public, max-age=15897599

I hope this article gives a glimpse on how easy Saturn is to use, but also extend when required.