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:
parent
68ba956587
commit
daae6f7879
4 changed files with 177 additions and 0 deletions
1
go.mod
1
go.mod
|
|
@ -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
2
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
10
options.go
10
options.go
|
|
@ -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
164
static_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue