diff --git a/go.mod b/go.mod index 7fc3645..185512a 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index ce67e61..9b9a819 100644 --- a/go.sum +++ b/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= diff --git a/options.go b/options.go index e0c4c66..64a1957 100644 --- a/options.go +++ b/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 { diff --git a/static_test.go b/static_test.go new file mode 100644 index 0000000..0113d24 --- /dev/null +++ b/static_test.go @@ -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("

Welcome

"), 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 != "

Welcome

" { + t.Fatalf("expected body=%q, got %q", "

Welcome

", 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()) + } +}