ZhangZhihui's Blog  

Using request context

At the moment our logic for authenticating a user consists of simply checking whether a "authenticatedUserID" value exists in their session data, like so:

func (app *application) isAuthenticated(r *http.Request) bool {
    return app.sessionManager.Exists(r.Context(), "authenticatedUserID")
}

We could make this check more robust by querying our users database table to make sure that the "authenticatedUserID" value is a real, valid value (i.e we haven’t deleted the user’s account since they last logged in).

But there is a slight problem with doing this additional database check.

Our isAuthenticated() helper can potentially be called multiple times in each request cycle. Currently we use it twice — once in the requireAuthentication() middleware and again in the newTemplateData() helper. So, if we query the database from the isAuthenticated() helper directly, we would end up making duplicated round-trips to the database during every request. And that’s not very efficient.

A better approach would be to carry out this check in some middleware to determine whether the current request is from an authenticated user or not, and then pass that information down to all subsequent handlers in the chain.

So how do we do this? Enter request context.

 

How request context works

Every http.Request that our middleware and handlers process has a context.Context object embedded in it, which we can use to store information during the lifetime of the request.

As I’ve already hinted at, in a web application a common use-case for this is to pass information between your pieces of middleware and other handlers.

In our case, we want to use it to check if a user is authenticated once in some middleware, and if they are, then make this information available to all our other middleware and handlers.

 

The request context syntax

The basic code for adding information to a request’s context looks like this:

// Where r is a *http.Request...
ctx := r.Context()
ctx = context.WithValue(ctx, "isAuthenticated", true)
r = r.WithContext(ctx)

Let’s step through this line-by-line.

  • First, we use the r.Context() method to retrieve the existing context from a request and assign it to the ctx variable.
  • Then we use the context.WithValue() method to create a new copy of the existing context, containing the key "isAuthenticated" and a value of true .
  • Then finally we use the r.WithContext() method to create a copy of the request containing our new context.

Important: Notice that we don’t actually update the context for a request directly. What we’re doing is creating a new copy of the http.Request object with our new context in it.

I should also point out that, for clarity, I made that code snippet a bit more verbose than it needs to be. It’s more typical to write it like this:

ctx = context.WithValue(r.Context(), "isAuthenticated", true)
r = r.WithContext(ctx)

So that’s how you add data to a request’s context. But what about retrieving it again?

The important thing to explain is that, behind the scenes, request context values are stored with the type any . And that means that, after retrieving them from the context, you’ll need to assert them to their original type before you use them.

To retrieve a value we need to use the r.Context().Value() method, like so:

isAuthenticated, ok := r.Context().Value("isAuthenticated").(bool)
if !ok {
    return errors.New("could not convert value to bool")
}

 

Avoiding key collisions

In the code samples above, I’ve used the string "isAuthenticated" as the key for storing and retrieving the data from a request’s context. But this isn’t recommended because there’s a risk that other third-party packages used by your application will also want to store data using the key "isAuthenticated" — and that would cause a naming collision.

To avoid this, it’s good practice to create your own custom type which you can use for your context keys. Extending our sample code, it’s much better to do something like this:

// Declare a custom "contextKey" type for your context keys.
type contextKey string

// Create a constant with the type contextKey that we can use.
const isAuthenticatedContextKey = contextKey("isAuthenticated")

...

// Set the value in the request context, using our isAuthenticatedContextKey constant as the key.
ctx := r.Context()
ctx = context.WithValue(ctx, isAuthenticatedContextKey, true)
r = r.WithContext(ctx)

...

// Retrieve the value from the request context using our constant as the key.
isAuthenticated, ok := r.Context().Value(isAuthenticatedContextKey).(bool)
if !ok {
    return errors.New("could not convert value to bool")
}

 

Request context for authentication/authorization

So, with those explanations out of the way, let’s start to use the request context functionality in our application.

We’ll begin by heading back to our internal/models/user.go file and fleshing out the UserModel.Exists() method, so that it returns true if a user with a specific ID exists in our users table, and false otherwise. Like so:

func (m *UserModel) Exists(id int) (bool, error) {
    var exists bool

    stmt := "SELECT EXISTS(SELECT true FROM user WHERE id = ?)"

    err := m.DB.QueryRow(stmt, id).Scan(&exists)

    return exists, err
}

Then let’s create a new cmd/web/context.go file. In this file we’ll define a custom contextKey type and an isAuthenticatedContextKey variable, so that we have a unique key we can use to store and retrieve the authentication status from a request context (without the risk of naming collisions).

package main

type contextKey string

const isAuthenticatedContextKey = contextKey("isAuthenticated")

And now for the exciting part. Let’s create a new authenticate() middleware method which:

1. Retrieves the user’s ID from their session data.
2. Checks the database to see if the ID corresponds to a valid user using the UserModel.Exists() method.
3. Updates the request context to include an isAuthenticatedContextKey key with the value true .

func (app *application) authenticate(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Retrive the authenticatedUserID value from the session using the GetInt() method. This 
        // will return the zero value for an int (0) if no "authenticatedUserID" value is in the 
        // session -- in which case we call the next handler in the chain as normal and return.
        id := app.sessionManager.GetInt(r.Context(), "authenticatedUserID")
        if id == 0 {
            next.ServeHTTP(w, r)
            return
        }

        // Otherwise, we check to see if a user with that ID exists in our database.
        exists, err := app.user.Exists(id)
        if err != nil {
            app.serverError(w, r, err)
            return
        }

        // If a matching user is found, we know that the request is coming from an authenticated 
        // user who exists in our database. We create a new copy of the request (with an 
        // isAuthenticatedContextKey value of true in the request context) and assign it ot r.
        if exists {
            ctx := context.WithValue(r.Context(), isAuthenticatedContextKey, true)
            r = r.WithContext(ctx)
        }

        next.ServeHTTP(w, r)
    })
}

The important thing to emphasize here is the following difference:

  • When we don’t have a valid authenticated user, we pass the original and unchanged *http.Request to the next handler in the chain.
  • When we do have a valid authenticated user, we create a copy of the request with a isAuthenticatedContextKey key and true value stored in the request context. We then pass this copy of *http.Request to the next handler in the chain.

Alright, let’s update the cmd/web/routes.go file to include the authenticate() middleware in our dynamic middleware chain:

package main

import (
    "net/http"

    "github.com/justinas/alice"
)

func (app *application) routes() http.Handler {
    mux := http.NewServeMux()

    fileServer := http.FileServer(http.Dir("./ui/static/"))
    mux.Handle("GET /static/", http.StripPrefix("/static", fileServer))

    // Unprotected routes using the "dynamic" middleware chain.
    // Add the authenticate middleware to the chain.
    dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)

    mux.Handle("GET /{$}", dynamic.ThenFunc(app.home))
    mux.Handle("GET /snippet/view/{id}", dynamic.ThenFunc(app.snippetView))
    mux.Handle("GET /user/signup", dynamic.ThenFunc(app.userSignup))
    mux.Handle("POST /user/signup", dynamic.ThenFunc(app.userSignupPost))
    mux.Handle("GET /user/login", dynamic.ThenFunc(app.userLogin))
    mux.Handle("POST /user/login", dynamic.ThenFunc(app.userLoginPost))

    // Protected (authenticated-only) routes using the "protected" middleware chain which includes 
    // the requireAuthentication middleware.
    protected := dynamic.Append(app.requireAuthentication)

    mux.Handle("GET /snippet/create", protected.ThenFunc(app.snippetCreate))
    mux.Handle("POST /snippet/create", protected.ThenFunc(app.snippetCreatePost))
    mux.Handle("POST /user/logout", protected.ThenFunc(app.userLogoutPost))

    standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders)

    return standard.Then(mux)
}

The last thing that we need to do is update our isAuthenticated() helper, so that instead of checking the session data it now checks the request context to determine if a user is authenticated or not.

func (app *application) isAuthenticated(r *http.Request) bool {
    isAuthenticated, ok := r.Context().Value(isAuthenticatedContextKey).(bool)
    if !ok {
        return false
    }

    return isAuthenticated
}

It’s important to point out here that if there isn’t a value in the request context with the isAuthenticatedContextKey key, or the underlying value isn’t a bool , then this type assertion will fail. In that case we take a ‘safe’ fall back and return false (i.e we assume that the user isn’t authenticated).

Then, if you want, open MySQL and delete the record for the user that you’re logged in as from the database.

And when you go back to your browser and refresh the page, the application is now smart enough to recognize that the user has been deleted, and you’ll find yourself treated as an unauthenticated (logged-out) user.

 

File embedding

The Go standard library includes an embed package, which makes it possible to embed external files into your Go program itself.

Using the embed package opens up the opportunity to create Go programs which are self-contained and have everything that they need to run as part of the compiled binary executable. In turn, that makes it easier to deploy or distribute your web application.

We’ll update our application so that it embeds the files from our ui directory — starting with the static CSS, JavaScript and image files, and then moving on to the HTML templates.

 

Embedding static files

The first thing to do is create a new ui/efs.go file:

package ui

import "embed"

//go:embed "static"
var Files embed.FS

The important line here is //go:embed "static" .

This looks like a comment, but it is actually a special comment directive. When our application is compiled (as part of either go build or go run ), this comment directive instructs Go to store the files from our ui/static folder in an embedded filesystem referenced by the global variable Files .

There are a few important details about this which we need to explain.

  • The comment directive must be placed immediately above the variable in which you want to store the embedded files.
  • The directive has the format go:embed "<path>" . The path is relative to the .go file containing the directive, so — in our case — go:embed "static" embeds the directory ui/static from our project.
  • You can only use the go:embed directive on global variables at package level, not within functions or methods. If you try to use it within a function or method, you’ll get the error "go:embed cannot apply to var inside func" at compile time.
  • Paths cannot not contain . or .. elements, nor may they begin or end with a / . This essentially restricts you to only embedding files or directories that are within the same directory as the .go file containing the go:embed directive.
  • The embedded file system is always rooted in the directory which contains the go:embed directive. So, in the example above, our Files variable contains an embed.FS embedded filesystem and the root of that filesystem is our ui directory.

 

Using the embedded static files

Now let’s switch up our application so that it serves our static CSS, JavaScript and image files from the embedded file system — instead of reading them from the disk at runtime.

Open your cmd/web/routes.go file and update it as follows:

package main

import (
    "net/http"
    "snippetbox/ui"

    "github.com/justinas/alice"
)

func (app *application) routes() http.Handler {
    mux := http.NewServeMux()

    // Use the http.FileServerFS() function to create an HTTP handler which serves the embedded 
    // files in ui.Files. It's important to note that our static files are contained in the 
    // "static" folder of the ui.Files embedded filesystem. So, for example, our CSS stylesheet 
    // is located at "static/css/main.css". This means that we no longer need to strip the prefix 
    // from the request URL -- any requests that start with /static/ can just be passed directly 
    // to the file server and the corresponding static file will be served (so long as it exists).
    mux.Handle("GET /static/", http.FileServerFS(ui.Files))

    // Unprotected routes using the "dynamic" middleware chain.
    // Add the authenticate middleware to the chain.
    dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)

    mux.Handle("GET /{$}", dynamic.ThenFunc(app.home))
    mux.Handle("GET /snippet/view/{id}", dynamic.ThenFunc(app.snippetView))
    mux.Handle("GET /user/signup", dynamic.ThenFunc(app.userSignup))
    mux.Handle("POST /user/signup", dynamic.ThenFunc(app.userSignupPost))
    mux.Handle("GET /user/login", dynamic.ThenFunc(app.userLogin))
    mux.Handle("POST /user/login", dynamic.ThenFunc(app.userLoginPost))

    // Protected (authenticated-only) routes using the "protected" middleware chain which includes 
    // the requireAuthentication middleware.
    protected := dynamic.Append(app.requireAuthentication)

    mux.Handle("GET /snippet/create", protected.ThenFunc(app.snippetCreate))
    mux.Handle("POST /snippet/create", protected.ThenFunc(app.snippetCreatePost))
    mux.Handle("POST /user/logout", protected.ThenFunc(app.userLogoutPost))

    standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders)

    return standard.Then(mux)
}

If you save these changes and then restart the application, you should find that everything compiles and runs correctly. When you visit https://localhost:4000 in your browser, the static files should be served from the embedded filesystem and everything should look normal.

If you want, you can also navigate directly to the static files to check that they are still available. For example, visiting https://localhost:4000/static/css/main.css should display the CSS stylesheet for the webpage from the embedded filesystem.

 

Multiple paths

It’s totally OK to specify multiple paths in one embed directive. For example, we could individually embed the ui/static/css , ui/static/img and ui/static/js directories like so:

//go:embed "static/css" "static/img" "static/js"
var Files embed.FS

Important: The path separator in embed path patterns should always be a forward slash / , even on Windows machines.

 

Embedding specific files

I alluded to this at the start of the chapter, but it’s possible for an embed path to point to a specific file. Embedding isn’t just limited to directories.

For example, lets’s pretend that our ui/static/css directory contains some additional assets that we don’t want to embed, such as Sass or Less files. In that case, we could embed just the ui/static/css/main.css file like so:

//go:embed "static/css/main.css" "static/img" "static/js"
var Files embed.FS

 

Wildcard paths

The character * can be used as a ‘wildcard’ in an embed path. Continuing with the example above, we could rewrite the embed directive so that only .css files under ui/static/css are embedded:

//go:embed "static/css/*.css" "static/img" "static/js"
var Files embed.FS

Related to that, if you use the wildcard path "*" without any qualifiers, like this:

//go:embed "*"
var Files embed.FS

… then it will embed everything in the current directory, including the .go file that contains the embed directive itself! Most of the time you don’t want that, so it’s more common to explicitly embed specific subdirectories or files instead.

 

The all prefix

Finally, if a path is to a directory, then all files in that directory are recursively embedded — except for files with names that begin with . or _ characters. If you want to include those files too, then you should use the all: prefix at the start of the path.

//go:embed "all:static"
var Files embed.FS

 

Embedding HTML templates

Next let’s update our application so that the template cache uses embedded HTML template files, instead reading them from your hard disk at runtime.

Head back to the ui/efs.go file, and update it so that ui.Files embeds the contents of the ui/html directory (which contains our templates) too. Like so:

package ui

import "embed"

//go:embed "html" "static"
var Files embed.FS

Then we need to update the newTemplateCache() function in cmd/web/template.go so that it reads the templates from ui.Files . To do this, we’ll need to leverage a couple of the special features that Go has for working with embedded filesystems:
The fs.Glob() function returns a slice of filepaths matching a glob pattern. It’s effectively the same as the filepath.Glob() function that we used earlier, except that it works on embedded filesystems.

The Template.ParseFS() method can be used to parse the HTML templates from an embedded filesystem into a template set. This is effectively a replacement for both the Template.ParseFiles() and Template.ParseGlob() methods that we used earlier. Template.ParseFiles() is also a variadic function, which allows you to parse multiple templates in a single call to ParseFiles() .

package main

import (
    "html/template"
    "io/fs"
    "path/filepath"
    "snippetbox/internal/models"
    "snippetbox/ui"
    "time"
)

type templateData struct {
    CurrentYear     int
    Snippet         models.Snippet
    Snippets        []models.Snippet
    Form            any
    Flash           string
    IsAuthenticated bool
    CSRFToken       string
}

func humanDate(t time.Time) string {
    return t.Format("02 Jan 2006 at 15:04")
}

var functions = template.FuncMap{
    "humanDate": humanDate,
}

func newTemplateCache() (map[string]*template.Template, error) {
    cache := map[string]*template.Template{}

    // Use fs.Glob() to get a slice of all filepaths in the ui.Files embedded filesystem which 
    // match the pattern 'html/pages/*.html'. This essentially gives us a slice of all the 'page' 
    // templates for the application, just like before.
    pages, err := fs.Glob(ui.Files, "html/pages/*.html")
    if err != nil {
        return nil, err
    }

    for _, page := range pages {
        name := filepath.Base(page)

        // Create a slice containing the filepath patterns for the template we want to parse.
        patterns := []string{
            "html/base.html",
            "html/partials/*.html",
            page,
        }

        // Use ParseFS() instead of ParseFiles() to parse the template files from the ui.Files 
        // embedded filesystem.
        ts, err := template.New(name).Funcs(functions).ParseFS(ui.Files, patterns...)
        if err != nil {
            return nil, err
        }

        cache[name] = ts
    }

    return cache, nil
}

Now that this is done, when our application is built into a binary it will contain all the UI files that it needs to run.

You can try this out quickly by building an executable binary in your /tmp directory, copying over the TLS certificates, and running the binary. Like so:

$ go build -o /tmp/web ./cmd/web/
$ cp -r ./tls /tmp/
$ cd /tmp/
$ ./web
time=2024-03-18T11:29:23.000+00:00 level=INFO msg="starting server" addr=:4000

And again, you should be able to visit https://localhost:4000 in your browser and everything should work correctly — despite the binary being in a location where it does not have access to the original UI files on disk.

 

posted on 2024-09-07 09:22  ZhangZhihuiAAA  阅读(3)  评论(0编辑  收藏  举报