feat: add Static and StaticFS helpers for serving static files
One-liner static file serving: v.Static("/assets/", "./public") for
filesystem directories and v.StaticFS("/assets/", fsys) for embed.FS.
Both auto-normalize the URL prefix and disable directory listings.
This commit is contained in:
143
static_test.go
Normal file
143
static_test.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package via
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatic(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(dir, "sub"), 0755)
|
||||||
|
os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello world"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(dir, "sub", "nested.txt"), []byte("nested"), 0644)
|
||||||
|
|
||||||
|
v := New()
|
||||||
|
v.Static("/assets/", dir)
|
||||||
|
|
||||||
|
t.Run("serves file", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/assets/hello.txt", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "hello world", w.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("serves nested file", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/assets/sub/nested.txt", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "nested", w.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("directory listing returns 404", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/assets/", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("subdirectory listing returns 404", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/assets/sub/", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file returns 404", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/assets/nope.txt", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaticAutoSlash(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(dir, "ok.txt"), []byte("ok"), 0644)
|
||||||
|
|
||||||
|
v := New()
|
||||||
|
v.Static("/files", dir) // no trailing slash
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/files/ok.txt", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "ok", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaticFS(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"style.css": {Data: []byte("body{}")},
|
||||||
|
"js/app.js": {Data: []byte("console.log('hi')")},
|
||||||
|
}
|
||||||
|
|
||||||
|
v := New()
|
||||||
|
v.StaticFS("/static/", fsys)
|
||||||
|
|
||||||
|
t.Run("serves file", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/static/style.css", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "body{}", w.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("serves nested file", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/static/js/app.js", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "console.log('hi')", w.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("directory listing returns 404", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/static/", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file returns 404", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/static/nope.css", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaticFSAutoSlash(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"ok.txt": {Data: []byte("ok")},
|
||||||
|
}
|
||||||
|
|
||||||
|
v := New()
|
||||||
|
v.StaticFS("/embed", fsys) // no trailing slash
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/embed/ok.txt", nil)
|
||||||
|
v.mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, "ok", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify StaticFS accepts the fs.FS interface (compile-time check).
|
||||||
|
var _ fs.FS = fstest.MapFS{}
|
||||||
43
via.go
43
via.go
@@ -9,12 +9,13 @@ package via
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
_ "embed"
|
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
_ "embed"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -412,6 +413,46 @@ func (v *V) HTTPServeMux() *http.ServeMux {
|
|||||||
return v.mux
|
return v.mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static serves files from a filesystem directory at the given URL prefix.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// v.Static("/assets/", "./public")
|
||||||
|
func (v *V) Static(urlPrefix, dir string) {
|
||||||
|
if !strings.HasSuffix(urlPrefix, "/") {
|
||||||
|
urlPrefix += "/"
|
||||||
|
}
|
||||||
|
fileServer := http.StripPrefix(urlPrefix, http.FileServer(http.Dir(dir)))
|
||||||
|
v.mux.Handle("GET "+urlPrefix, noDirListing(fileServer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaticFS serves files from an [fs.FS] at the given URL prefix.
|
||||||
|
// This is useful with //go:embed filesystems.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// //go:embed static
|
||||||
|
// var staticFiles embed.FS
|
||||||
|
// v.StaticFS("/assets/", staticFiles)
|
||||||
|
func (v *V) StaticFS(urlPrefix string, fsys fs.FS) {
|
||||||
|
if !strings.HasSuffix(urlPrefix, "/") {
|
||||||
|
urlPrefix += "/"
|
||||||
|
}
|
||||||
|
fileServer := http.StripPrefix(urlPrefix, http.FileServerFS(fsys))
|
||||||
|
v.mux.Handle("GET "+urlPrefix, noDirListing(fileServer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// noDirListing wraps a file server handler to return 404 for directory requests.
|
||||||
|
func noDirListing(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (v *V) ensureDatastarHandler() {
|
func (v *V) ensureDatastarHandler() {
|
||||||
v.datastarOnce.Do(func() {
|
v.datastarOnce.Do(func() {
|
||||||
v.mux.HandleFunc("GET "+v.datastarPath, func(w http.ResponseWriter, r *http.Request) {
|
v.mux.HandleFunc("GET "+v.datastarPath, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
Reference in New Issue
Block a user