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.
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.