feat: add WithSessions server-side session middleware

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-20 23:44:46 +00:00
parent 0ab962a258
commit e00ef00db8
4 changed files with 225 additions and 0 deletions

4
go.mod
View file

@ -8,6 +8,7 @@ require (
github.com/gin-contrib/cors v1.7.6
github.com/gin-contrib/gzip v1.2.5
github.com/gin-contrib/secure v1.1.2
github.com/gin-contrib/sessions v1.0.4
github.com/gin-contrib/slog v1.2.0
github.com/gin-contrib/static v1.1.5
github.com/gin-contrib/timeout v1.1.0
@ -38,6 +39,9 @@ require (
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect

10
go.sum
View file

@ -28,6 +28,8 @@ github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjck
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
github.com/gin-contrib/secure v1.1.2 h1:6G8/NCOTSywWY7TeaH/0Yfaa6bfkE5ukkqtIm7lK11U=
github.com/gin-contrib/secure v1.1.2/go.mod h1:xI3jI5/BpOYMCBtjgmIVrMA3kI7y9LwCFxs+eLf5S3w=
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@ -65,6 +67,14 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=

View file

@ -11,6 +11,8 @@ import (
"github.com/gin-contrib/cors"
gingzip "github.com/gin-contrib/gzip"
"github.com/gin-contrib/secure"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
ginslog "github.com/gin-contrib/slog"
"github.com/gin-contrib/static"
"github.com/gin-contrib/timeout"
@ -206,3 +208,14 @@ func WithCache(ttl time.Duration) Option {
e.middlewares = append(e.middlewares, cacheMiddleware(store, ttl))
}
}
// WithSessions adds server-side session management middleware via
// gin-contrib/sessions using a cookie-based store. The name parameter
// sets the session cookie name (e.g. "session") and secret is the key
// used for cookie signing and encryption.
func WithSessions(name string, secret []byte) Option {
return func(e *Engine) {
store := cookie.NewStore(secret)
e.middlewares = append(e.middlewares, sessions.Sessions(name, store))
}
}

198
sessions_test.go Normal file
View file

@ -0,0 +1,198 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/go-api"
)
// ── Helpers ─────────────────────────────────────────────────────────────
// sessionTestGroup provides /sess/set and /sess/get endpoints for session tests.
type sessionTestGroup struct{}
func (s *sessionTestGroup) Name() string { return "sess" }
func (s *sessionTestGroup) BasePath() string { return "/sess" }
func (s *sessionTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.POST("/set", func(c *gin.Context) {
session := sessions.Default(c)
session.Set("key", "value")
session.Save()
c.JSON(http.StatusOK, api.OK("saved"))
})
rg.GET("/get", func(c *gin.Context) {
session := sessions.Default(c)
val := session.Get("key")
c.JSON(http.StatusOK, api.OK(val))
})
}
// ── WithSessions ────────────────────────────────────────────────────────
func TestWithSessions_Good_SetsSessionCookie(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!")))
e.Register(&sessionTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
cookies := w.Result().Cookies()
found := false
for _, c := range cookies {
if c.Name == "session" {
found = true
break
}
}
if !found {
t.Fatal("expected Set-Cookie header with name 'session'")
}
}
func TestWithSessions_Good_SessionPersistsAcrossRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!")))
e.Register(&sessionTestGroup{})
h := e.Handler()
// First request: set session value.
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodPost, "/sess/set", nil)
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("set: expected 200, got %d", w1.Code)
}
// Extract the session cookie from the response.
var sessionCookie *http.Cookie
for _, c := range w1.Result().Cookies() {
if c.Name == "session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("set: expected session cookie in response")
}
// Second request: get session value, sending the cookie back.
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/sess/get", nil)
req2.AddCookie(sessionCookie)
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("get: expected 200, got %d", w2.Code)
}
var resp api.Response[any]
if err := json.Unmarshal(w2.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
data, ok := resp.Data.(string)
if !ok || data != "value" {
t.Fatalf("expected Data=%q, got %v", "value", resp.Data)
}
}
func TestWithSessions_Good_EmptySessionReturnsNil(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!")))
e.Register(&sessionTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/sess/get", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp api.Response[any]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Data != nil {
t.Fatalf("expected nil Data for empty session, got %v", resp.Data)
}
}
func TestWithSessions_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(
api.WithSessions("session", []byte("test-secret-key!")),
api.WithRequestID(),
)
e.Register(&sessionTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// Session cookie should be present.
found := false
for _, c := range w.Result().Cookies() {
if c.Name == "session" {
found = true
break
}
}
if !found {
t.Fatal("expected session cookie")
}
// Request ID should also be present.
rid := w.Header().Get("X-Request-ID")
if rid == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
func TestWithSessions_Ugly_DoubleSessionsDoesNotPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
// Applying WithSessions twice should not panic.
e, err := api.New(
api.WithSessions("session", []byte("secret-one-here!")),
api.WithSessions("session", []byte("secret-two-here!")),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e.Register(&sessionTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}