feat: add WithStatic static file serving middleware

Adds WithStatic(urlPrefix, root) option using gin-contrib/static to
serve files from a local directory at the given URL prefix. Directory
listing is disabled for security.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-20 23:23:17 +00:00
parent 68ba956587
commit daae6f7879
4 changed files with 177 additions and 0 deletions

1
go.mod
View file

@ -8,6 +8,7 @@ require (
github.com/gin-contrib/gzip v1.2.5
github.com/gin-contrib/secure v1.1.2
github.com/gin-contrib/slog v1.2.0
github.com/gin-contrib/static v1.1.5
github.com/gin-contrib/timeout v1.1.0
github.com/gin-gonic/gin v1.11.0
github.com/gorilla/websocket v1.5.3

2
go.sum
View file

@ -30,6 +30,8 @@ github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hS
github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-contrib/static v1.1.5 h1:bAPqT4KTZN+4uDY1b90eSrD1t8iNzod7Jj8njwmnzz4=
github.com/gin-contrib/static v1.1.5/go.mod h1:8JSEXwZHcQ0uCrLPcsvnAJ4g+ODxeupP8Zetl9fd8wM=
github.com/gin-contrib/timeout v1.1.0 h1:WAmWseo5gfBUbMrMJu5hJxDclehfSJUmK2wGwCC/EFw=
github.com/gin-contrib/timeout v1.1.0/go.mod h1:NpRo4gd1Ad8ZQ4T6bQLVFDqiplCmPRs2nvfckxS2Fw4=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=

View file

@ -12,6 +12,7 @@ import (
gingzip "github.com/gin-contrib/gzip"
"github.com/gin-contrib/secure"
ginslog "github.com/gin-contrib/slog"
"github.com/gin-contrib/static"
"github.com/gin-contrib/timeout"
"github.com/gin-gonic/gin"
)
@ -76,6 +77,15 @@ func WithMiddleware(mw ...gin.HandlerFunc) Option {
}
}
// WithStatic serves static files from the given root directory at urlPrefix.
// Directory listing is disabled; only individual files are served.
// Internally this uses gin-contrib/static as Gin middleware.
func WithStatic(urlPrefix, root string) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, static.Serve(urlPrefix, static.LocalFile(root, false)))
}
}
// WithWSHandler registers a WebSocket handler at GET /ws.
// Typically this wraps a go-ws Hub.Handler().
func WithWSHandler(h http.Handler) Option {

164
static_test.go Normal file
View file

@ -0,0 +1,164 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/go-api"
)
// ── WithStatic ──────────────────────────────────────────────────────────
func TestWithStatic_Good_ServesFile(t *testing.T) {
gin.SetMode(gin.TestMode)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello world"), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
e, _ := api.New(api.WithStatic("/assets", dir))
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/assets/hello.txt", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if body != "hello world" {
t.Fatalf("expected body=%q, got %q", "hello world", body)
}
}
func TestWithStatic_Good_Returns404ForMissing(t *testing.T) {
gin.SetMode(gin.TestMode)
dir := t.TempDir()
e, _ := api.New(api.WithStatic("/assets", dir))
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/assets/nonexistent.txt", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestWithStatic_Good_ServesIndex(t *testing.T) {
gin.SetMode(gin.TestMode)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<h1>Welcome</h1>"), 0644); err != nil {
t.Fatalf("failed to write index.html: %v", err)
}
e, _ := api.New(api.WithStatic("/docs", dir))
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/docs/", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if body != "<h1>Welcome</h1>" {
t.Fatalf("expected body=%q, got %q", "<h1>Welcome</h1>", body)
}
}
func TestWithStatic_Good_CombinesWithRouteGroups(t *testing.T) {
gin.SetMode(gin.TestMode)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("console.log('ok')"), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
e, _ := api.New(api.WithStatic("/static", dir))
e.Register(&stubGroup{})
h := e.Handler()
// Static file should be served.
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/static/app.js", nil)
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("static: expected 200, got %d", w1.Code)
}
if w1.Body.String() != "console.log('ok')" {
t.Fatalf("static: unexpected body %q", w1.Body.String())
}
// API route should also work.
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("api: expected 200, got %d", w2.Code)
}
}
func TestWithStatic_Good_MultipleStaticDirs(t *testing.T) {
gin.SetMode(gin.TestMode)
dir1 := t.TempDir()
if err := os.WriteFile(filepath.Join(dir1, "sdk.zip"), []byte("sdk-data"), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
dir2 := t.TempDir()
if err := os.WriteFile(filepath.Join(dir2, "style.css"), []byte("body{}"), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
e, _ := api.New(
api.WithStatic("/downloads", dir1),
api.WithStatic("/css", dir2),
)
h := e.Handler()
// First static directory.
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/downloads/sdk.zip", nil)
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("downloads: expected 200, got %d", w1.Code)
}
if w1.Body.String() != "sdk-data" {
t.Fatalf("downloads: expected body=%q, got %q", "sdk-data", w1.Body.String())
}
// Second static directory.
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/css/style.css", nil)
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("css: expected 200, got %d", w2.Code)
}
if w2.Body.String() != "body{}" {
t.Fatalf("css: expected body=%q, got %q", "body{}", w2.Body.String())
}
}