Session Management with Gin
- 8 minutes read - 1507 wordsThis is the eleventh in a series of articles about writing a small reading list app in Go for personal use.
When a user adds a new book to the app, they just get a page-refresh back to the index page. There’s nothing that says “hey it worked”. And if the user has more than fifteen books in their list, the new book won’t even show up on the first page, so they have to flip to the back to make sure it’s there.
In this article I’ll show how to provide that user feedback using flash messages. In order to show flash messages, some kind of session management is needed, so I’ll show that too.

Pennington Flash in Greater Manchester, England. by Jeremy Atkinson CC-BY-2.0
Adding Sessions
In a nutshell, we want the controller for Page A to be able to set a message when Page B is shown in the browser. Since HTTP is inherently stateless, we need to store the flash message somewhere. We could explicitly set flash messages in a cookie, but instead we’ll use a more general storage mechanism that will allow us to save arbitrary data across a user’s page fetches.
That storage mechanism is going to be cookie-based session storage, using the sessions package from gin-contrib. This package offers a number of backends, but we’re just going to use the cookie-based session for simplicity.
First we have to go get
the package:
% go get github.com/gin-contrib/sessions
go: downloading github.com/gin-contrib/sessions v0.0.5
go: downloading golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f
go: downloading golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83
go: downloading github.com/mattn/go-sqlite3 v2.0.3+incompatible
go: added github.com/gin-contrib/sessions v0.0.5
go: added github.com/gorilla/context v1.1.1
go: added github.com/gorilla/securecookie v1.1.1
go: added github.com/gorilla/sessions v1.2.1
go: upgraded github.com/mattn/go-sqlite3 v1.14.9 => v2.0.3+incompatible
go: upgraded golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 => v0.0.0-20210220033148-5ea612d1eb83
go: upgraded golang.org/x/sys v0.0.0-20200116001909-b77594299b42 => v0.0.0-20220408201424-a24fb2fb8a0f
There’s just a little bit of code required in setupRouter
(the rest of
the function is unchanged):
func setupRouter(r *gin.Engine, db *gorm.DB) {
sessionKey := os.Getenv("AKLATAN_SESSION_KEY")
if sessionKey == "" {
log.Fatal("error: set AKLATAN_SESSION_KEY to a secret string and try again")
}
store := cookie.NewStore([]byte(sessionKey))
r.Use(sessions.Sessions("mysession", store))
The sessions package requires a secret key. We fetch this key from the environment – hard-coding it into the source is insecure and a really bad idea. If the environment variable is not set, it will log an error and quit. This means that when we make this change the tests will start failing because they don’t have that key set.
To fix this, call os.Setenv
in the test code before calling
setupRouter
. Here, I’ve shown the change to getHasStatus
; a similar
change has to be made to postHasStatus
but I won’t show that here. Also,
I realized when I made this change that the fuzz test I added last week
isn’t using getHasStatus
, it has a copy of that code inside it. You can
either add another os.Setenv
there to fix it, or you can refactor it to
use getHasStatus
. I’ve opted for the refactoring; it’s not shown here but
you can see it in the gitlab MR corresponding to this
post.
func getHasStatus(t *testing.T, db *gorm.DB, path string, status int) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
ctx, router := gin.CreateTestContext(w)
os.Setenv("AKLATAN_SESSION_KEY", "dummy")
setupRouter(router, db)
// ...
And that’s all there really is to getting sessions set up.
Setting a Flash Message
Let’s define a helper function to set a flash message; add this to
main.go
:
func flashMessage(c *gin.Context, message string) {
session := sessions.Default(c)
session.AddFlash(message)
if err := session.Save(); err != nil {
log.Printf("error in flashMessage saving session: %s", err)
}
}
In order to set a flash message, first we have to get the session off the
context, then add the message, and then save the session – making sure to
check the error from the Save
function. This isn’t a lot of code, but
since our app is only going to set flash messages in one way, we don’t need
to have these five lines sprinkled around all the handlers.
Then all we have to do is call flashMessage
with the gin context and our
message string at the bottom of our POST handler:
func bookNewPost(c *gin.Context) {
book := &Book{}
if err := c.ShouldBind(book); err != nil {
verrs := err.(validator.ValidationErrors)
messages := make([]string, len(verrs))
for i, verr := range verrs {
messages[i] = fmt.Sprintf(
"%s is required, but was empty.",
verr.Field())
}
c.HTML(http.StatusBadRequest, "books/new.html",
gin.H{"errors": messages})
return
}
db := c.Value("database").(*gorm.DB)
if err := db.Create(&book).Error; err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
flashMessage(c, fmt.Sprintf("New book '%s' saved successfully.", book.Title))
c.Redirect(http.StatusFound, "/books/")
}
You can build this, and run it, and posting a new book works but… the flash message isn’t displayed anywhere yet.
Reading Flash Messages
Let’s make another helper function to get the messages; this can go in
main.go
right next to flashMessages
:
func flashes(c *gin.Context) []interface{} {
session := sessions.Default(c)
flashes := session.Flashes()
if len(flashes) != 0 {
if err := session.Save(); err != nil {
log.Printf("error in flashes saving session: %s", err)
}
}
return flashes
}
Note that calling session.Flashes
will clear the messages from the
session – though we have to call session.Save
to have that stick.
Now, to display the flash messages, we need a spot for them in a template.
And since we want any flash message to show up on any page, we probably
want to put this in the header template (templates/base/header.html
),
just below the header (only the bottom half of the template is shown here):
</header>
{{ if .messages }}
{{ range .messages }}
<div class="flash-message" role="alert">
{{ . }}
</div>
{{ end }}
{{ end }}
<main role="main">
{{ end }}
Then, in every GET handler, we need to fetch the messages and pass them
through to the template. For example, here at the bottom of bookIndexGet
:
c.HTML(http.StatusOK, "books/index.html", gin.H{
"books": books,
"messages": flashes(c),
"p": p,
})
bookNewGet
also needs this change (not shown here).
Now build the app, run it, add a new book, and notice that there’s a “New
book ‘mytitle’ saved successfully.” message just under the navbar. It
works, but it needs a little styling. Add this at the bottom of
static/css/styles.css
:
.flash-message {
font-weight: bolder;
color: #222222;
margin-bottom: 1em;
background-color: #f5f2aa;
padding: 0.75em;
}
Testing
Testing this requires accepting the cookie that gets set by a POST and
sending that cookie on a subsequent GET. Since we already have some code
that does a POST and checks the redirect location returned by that handler,
we can add an extra check there. Add this code at the bottom of
TestBookNewPost
:
// Check the redirect location.
url, err := w.Result().Location()
if err != nil {
t.Fatalf("location check error: %s", err)
}
if "/books/" != url.String() {
t.Errorf("expected location '/books/', got '%s'",
url.String())
}
// ----- NEW CODE BEGINS HERE --- 8< ---
// Check the flash message in the redirect page.
w := getCookieHasStatus(t, db, url.String(), w.Result(), http.StatusOK)
fragments := []string{
fmt.Sprintf("New book '%s' saved successfully.", books[0].Title),
}
bodyHasFragments(t, w.Body.String(), fragments)
// ----- NEW CODE ENDS HERE --- >8 ---
}
})
}
}
Since it needs to pass in a cookie it is using a new helper function,
getCookieHasStatus
, which you can add to main_test.go
right below
getHasStatus
:
func getCookieHasStatus(t *testing.T, db *gorm.DB, path string, r *http.Response, status int) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
ctx, router := gin.CreateTestContext(w)
os.Setenv("AKLATAN_SESSION_KEY", "dummy")
setupRouter(router, db)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
t.Errorf("got error: %s", err)
}
if r != nil {
req.Header["Cookie"] = r.Header["Set-Cookie"]
}
router.ServeHTTP(w, req)
if status != w.Code {
t.Errorf("expected response code %d, got %d", status, w.Code)
}
return w
}
This is mostly the same as getHasStatus (and there are some refactoring
opportunities here, but I’m not tackling that now). The main difference is
that it sets the Cookie
header on the request using the Set-Cookie
header on the response.
This gives us “pretty good” coverage. It’s missing test cases to exercise
the handling of errors from session.Save
– I’m not really sure there’s a
good way to force that with the cookie backend. This would require a little
code exploration in gin-contrib/sessions
and gorilla/sessions
. And if
we wanted to be really thorough we could check for a lack of flash
messages by reloading the page after seeing the flash message. Because
we’re supposed to be clearing the message after it has been seen once. In
production code I’d chase these down, but for here I think “pretty good” is
“good enough”.
Next Week
This series is not done, but new articles in this series will be paused for a few weeks. I’m going to switch to taking a look at various tools in the Go ecosystem. First up are build tools. Next week I will port Aklatan’s Makefile to mage, share the result, and report on the experience.