Add HTTP file-server helper
This commit is contained in:
145
pkg/http/file_server.go
Normal file
145
pkg/http/file_server.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
// special file-server meant to be used for serving single page applications
|
||||
// or anything that needs a bit of extra attention like fallback handling
|
||||
// 404 handler, error handler and URL prefixing
|
||||
fileServer struct {
|
||||
// fileServer files
|
||||
files http.FileSystem
|
||||
|
||||
urlPrefix string
|
||||
|
||||
fallbacks []string
|
||||
|
||||
// Final not-found handler
|
||||
notFound http.HandlerFunc
|
||||
|
||||
// how errors are handled
|
||||
errHandler func(w http.ResponseWriter, error string, code int)
|
||||
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
configurator func(*fileServer) error
|
||||
)
|
||||
|
||||
// MountSPA helper function, preconfigures FileServer for SPA serving
|
||||
// and mounts it to chi Router
|
||||
func MountSPA(r chi.Router, path string, root fs.FS, cc ...configurator) error {
|
||||
path = "/" + strings.Trim(strings.TrimRight(path, "*"), "/") + "/"
|
||||
handler, err := FileServer(root, append(cc, UrlPrefix(path), Fallbacks("index.html"))...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.Handle(
|
||||
strings.TrimRight(path, "/"),
|
||||
http.RedirectHandler("."+path, http.StatusTemporaryRedirect),
|
||||
)
|
||||
|
||||
r.Handle(path+"*", handler)
|
||||
return nil
|
||||
}
|
||||
|
||||
func FileServer(files fs.FS, cc ...configurator) (h *fileServer, err error) {
|
||||
h = &fileServer{
|
||||
files: http.FS(files),
|
||||
notFound: http.NotFound,
|
||||
errHandler: http.Error,
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
|
||||
for _, configure := range cc {
|
||||
if err = configure(h); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func UrlPrefix(prefix string) configurator {
|
||||
return func(s *fileServer) error {
|
||||
s.urlPrefix = prefix
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func Fallbacks(ff ...string) configurator {
|
||||
return func(s *fileServer) error {
|
||||
s.fallbacks = ff
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NotFound(h http.HandlerFunc) configurator {
|
||||
return func(s *fileServer) error {
|
||||
s.notFound = h
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func Logger(l *zap.Logger) configurator {
|
||||
return func(s *fileServer) error {
|
||||
s.logger = l
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Serves the single-page-application
|
||||
//
|
||||
// This is file-server with some special logic for handling missing
|
||||
// files (404s) and directories.
|
||||
// In both cases we serve index file directly
|
||||
func (h *fileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// catch requests for non-existing files and redirect to index.html
|
||||
if h.files == nil {
|
||||
h.errHandler(w, "configured without files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
trimmed := path.Clean(strings.TrimPrefix(r.URL.Path, h.urlPrefix))
|
||||
h.logger.Debug(r.URL.Path, zap.String("trimmed", trimmed), zap.String("urlPrefix", h.urlPrefix))
|
||||
r.URL.Path = trimmed
|
||||
|
||||
var (
|
||||
err error
|
||||
fh http.File
|
||||
st fs.FileInfo
|
||||
)
|
||||
|
||||
for _, candidate := range append([]string{r.URL.Path}, h.fallbacks...) {
|
||||
if len(candidate) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if fh, err = h.files.Open(candidate); err != nil {
|
||||
continue
|
||||
} else if st, err = fh.Stat(); err != nil {
|
||||
continue
|
||||
} else if st.IsDir() {
|
||||
// index
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if fh == nil || st == nil {
|
||||
h.notFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, st.Name(), st.ModTime(), fh)
|
||||
}
|
||||
133
pkg/http/file_server_test.go
Normal file
133
pkg/http/file_server_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed test_data/file_server
|
||||
testAssets embed.FS
|
||||
)
|
||||
|
||||
func TestSPA(t *testing.T) {
|
||||
testAssetsSub, err := fs.Sub(testAssets, "test_data/file_server")
|
||||
if err != nil {
|
||||
t.Errorf("failed to dive into subdirectory: %w", err)
|
||||
}
|
||||
|
||||
testDirectAssets := os.DirFS("test_data/file_server")
|
||||
|
||||
var (
|
||||
cases = []struct {
|
||||
name string
|
||||
fs fs.FS
|
||||
cc []configurator
|
||||
url string
|
||||
rsp string
|
||||
}{
|
||||
{
|
||||
name: "no special config",
|
||||
fs: testAssetsSub,
|
||||
url: "index.html",
|
||||
rsp: "index html\n",
|
||||
},
|
||||
|
||||
{
|
||||
name: "empty path",
|
||||
fs: testAssetsSub,
|
||||
cc: []configurator{Fallbacks("index.html")},
|
||||
url: "",
|
||||
rsp: "index html\n",
|
||||
},
|
||||
{
|
||||
name: "slash",
|
||||
fs: testAssetsSub,
|
||||
cc: []configurator{Fallbacks("index.html")},
|
||||
url: "/",
|
||||
rsp: "index html\n",
|
||||
},
|
||||
|
||||
{
|
||||
name: "prefix, no slash",
|
||||
fs: testAssetsSub,
|
||||
cc: []configurator{UrlPrefix("/my-spa"), Fallbacks("index.html")},
|
||||
url: "/my-spa",
|
||||
rsp: "index html\n",
|
||||
},
|
||||
|
||||
{
|
||||
name: "sub dir with url prefix",
|
||||
fs: testAssetsSub,
|
||||
cc: []configurator{UrlPrefix("/my-spa"), Fallbacks("index.html")},
|
||||
url: "/my-spa/index.html",
|
||||
rsp: "index html\n",
|
||||
},
|
||||
{
|
||||
name: "sub dir with url prefix, none existing file",
|
||||
fs: testAssetsSub,
|
||||
cc: []configurator{UrlPrefix("/my-spa"), Fallbacks("index.html")},
|
||||
url: "/not-here",
|
||||
rsp: "index html\n",
|
||||
},
|
||||
{
|
||||
name: "sub dir with url prefix, dir",
|
||||
fs: testAssetsSub,
|
||||
cc: []configurator{UrlPrefix("/my-spa"), Fallbacks("index.html")},
|
||||
url: "/my-spa/sub",
|
||||
rsp: "index html\n",
|
||||
},
|
||||
{
|
||||
name: "sub dir with url prefix, sub index",
|
||||
fs: testAssetsSub,
|
||||
cc: []configurator{UrlPrefix("/my-spa"), Fallbacks("index.html")},
|
||||
url: "/my-spa/sub/index.html",
|
||||
rsp: "sub index html\n",
|
||||
},
|
||||
{
|
||||
name: "sub dir with url prefix, sub file",
|
||||
fs: testAssetsSub,
|
||||
cc: []configurator{UrlPrefix("/my-spa"), Fallbacks("index.html")},
|
||||
url: "/my-spa/sub/test.html",
|
||||
rsp: "sub test html\n",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// another set of test cases for direct fs
|
||||
total := len(cases)
|
||||
for c := 0; c < total; c++ {
|
||||
direct := cases[c]
|
||||
cases[c].name += "; embedded"
|
||||
direct.name += "; direct"
|
||||
direct.fs = testDirectAssets
|
||||
|
||||
cases = append(cases, direct)
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var (
|
||||
handler http.Handler
|
||||
req = require.New(t)
|
||||
w = httptest.NewRecorder()
|
||||
r, err = http.NewRequest(http.MethodGet, c.url, nil)
|
||||
)
|
||||
|
||||
handler, err = FileServer(c.fs, c.cc...)
|
||||
|
||||
req.NoError(err)
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
req.Equal(c.rsp, w.Body.String())
|
||||
req.Equal(http.StatusOK, w.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
1
pkg/http/test_data/file_server/index.html
Normal file
1
pkg/http/test_data/file_server/index.html
Normal file
@@ -0,0 +1 @@
|
||||
index html
|
||||
1
pkg/http/test_data/file_server/sub/index.html
Normal file
1
pkg/http/test_data/file_server/sub/index.html
Normal file
@@ -0,0 +1 @@
|
||||
sub index html
|
||||
1
pkg/http/test_data/file_server/sub/test.html
Normal file
1
pkg/http/test_data/file_server/sub/test.html
Normal file
@@ -0,0 +1 @@
|
||||
sub test html
|
||||
1
pkg/http/test_data/file_server/test.html
Normal file
1
pkg/http/test_data/file_server/test.html
Normal file
@@ -0,0 +1 @@
|
||||
test html
|
||||
Reference in New Issue
Block a user