3
0

Add HTTP file-server helper

This commit is contained in:
Denis Arh
2022-02-15 19:08:37 +01:00
parent e0bcaf8662
commit f5eaa7aedf
6 changed files with 282 additions and 0 deletions

145
pkg/http/file_server.go Normal file
View 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)
}

View 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)
})
}
}

View File

@@ -0,0 +1 @@
index html

View File

@@ -0,0 +1 @@
sub index html

View File

@@ -0,0 +1 @@
sub test html

View File

@@ -0,0 +1 @@
test html