Skip to content

Commit 785982a

Browse files
committed
Add FlexibleFileServer
1 parent e83598f commit 785982a

File tree

2 files changed

+354
-0
lines changed

2 files changed

+354
-0
lines changed

flexiblefileserver.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) 2025, Janoš Guljaš <janos@resenje.org>
2+
// All rights reserved.
3+
// Use of this source code is governed by a BSD-style
4+
// license that can be found in the LICENSE file.
5+
6+
package web
7+
8+
import (
9+
"errors"
10+
"io"
11+
"io/fs"
12+
"log/slog"
13+
"net/http"
14+
)
15+
16+
// FlexibleFileServer creates and returns a new http.Handler middleware.
17+
// The resolver function is responsible for finding and opening the file to be
18+
// served, returning an fs.File handle. The middleware takes ownership of the
19+
// returned fs.File and is responsible for closing it.
20+
// If any step fails, the error is logged and the request is passed to the next handler.
21+
func FlexibleFileServer(resolver func(r *http.Request) (file fs.File, headers http.Header, err error), logger *slog.Logger, next http.Handler) (http.Handler, error) {
22+
if resolver == nil {
23+
return nil, errors.New("flexiblefs: resolver cannot be nil")
24+
}
25+
if next == nil {
26+
return nil, errors.New("flexiblefs: next handler cannot be nil")
27+
}
28+
29+
if logger == nil {
30+
// Default to a logger that discards all output if none is provided.
31+
logger = slog.New(slog.NewTextHandler(io.Discard, nil))
32+
}
33+
34+
return &fileServer{
35+
resolver: resolver,
36+
logger: logger,
37+
next: next,
38+
}, nil
39+
}
40+
41+
// fileServer is an http.Handler that serves files provided by its resolver function.
42+
// It acts as middleware, calling the next handler if it encounters an error.
43+
type fileServer struct {
44+
resolver func(r *http.Request) (file fs.File, headers http.Header, err error)
45+
logger *slog.Logger
46+
next http.Handler
47+
}
48+
49+
// ServeHTTP uses the resolver to get a file handle. If successful, it serves
50+
// the file using http.ServeContent. If any error occurs, it logs the error
51+
// and calls the next handler in the chain. It takes ownership of the fs.File
52+
// returned by the resolver and ensures it is closed.
53+
func (h *fileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
54+
// 1. Use the resolver to get the file handle and custom headers.
55+
file, customHeaders, err := h.resolver(r)
56+
if err != nil {
57+
h.logger.Error("resolver error", "method", r.Method, "url", r.URL.String(), "error", err)
58+
h.next.ServeHTTP(w, r)
59+
return
60+
}
61+
// The handler takes ownership of the file and guarantees it will be closed.
62+
defer func() { _ = file.Close() }()
63+
64+
// 2. Get file stats to provide to ServeContent.
65+
stat, err := file.Stat()
66+
if err != nil {
67+
h.logger.Error("failed to stat file", "method", r.Method, "url", r.URL.String(), "error", err)
68+
h.next.ServeHTTP(w, r)
69+
return
70+
}
71+
72+
// 3. Apply custom headers returned by the resolver before serving content.
73+
for key, values := range customHeaders {
74+
// Using Add, not Set, to allow for multiple values for the same header key.
75+
for _, value := range values {
76+
w.Header().Add(key, value)
77+
}
78+
}
79+
80+
// 4. An fs.File is not guaranteed to implement io.Seeker.
81+
// http.ServeContent requires an io.ReadSeeker. We must check for this.
82+
seeker, ok := file.(io.ReadSeeker)
83+
if !ok {
84+
h.logger.Error("file is not seekable", "method", r.Method, "url", r.URL.String())
85+
h.next.ServeHTTP(w, r)
86+
return
87+
}
88+
89+
// 5. Use http.ServeContent to handle the actual serving.
90+
// This function correctly handles Range requests (for partial content),
91+
// sets the ETag header, and manages If-Modified-Since caching headers.
92+
// Passing an empty string for the name triggers Content-Type sniffing.
93+
http.ServeContent(w, r, "", stat.ModTime(), seeker)
94+
}

flexiblefileserver_test.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
// Copyright (c) 2025, Janoš Guljaš <janos@resenje.org>
2+
// All rights reserved.
3+
// Use of this source code is governed by a BSD-style
4+
// license that can be found in the LICENSE file.
5+
6+
package web_test
7+
8+
import (
9+
"bytes"
10+
"errors"
11+
"io"
12+
"io/fs"
13+
"log/slog"
14+
"net/http"
15+
"net/http/httptest"
16+
"strings"
17+
"testing"
18+
"testing/fstest"
19+
"time"
20+
21+
"resenje.org/web"
22+
)
23+
24+
// TestFlexibleFileServer_Success verifies the primary success scenarios.
25+
func TestFlexibleFileServer_Success(t *testing.T) {
26+
// Arrange: A mock filesystem that the resolver can pull from.
27+
mockFS := fstest.MapFS{
28+
"test.txt": {Data: []byte("hello world"), ModTime: time.Now()},
29+
}
30+
31+
testCases := []struct {
32+
name string
33+
resolver func(r *http.Request) (fs.File, http.Header, error)
34+
expectedBody string
35+
expectedHeaders http.Header
36+
}{
37+
{
38+
name: "Simple file serving",
39+
resolver: func(r *http.Request) (fs.File, http.Header, error) {
40+
file, _ := mockFS.Open("test.txt")
41+
return file, nil, nil
42+
},
43+
expectedBody: "hello world",
44+
expectedHeaders: http.Header{"Content-Type": {"text/plain; charset=utf-8"}},
45+
},
46+
{
47+
name: "File serving with custom headers",
48+
resolver: func(r *http.Request) (fs.File, http.Header, error) {
49+
file, _ := mockFS.Open("test.txt")
50+
headers := http.Header{
51+
"Content-Disposition": {"attachment; filename=\"custom.txt\""},
52+
"X-Custom-Header": {"value123"},
53+
}
54+
return file, headers, nil
55+
},
56+
expectedBody: "hello world",
57+
expectedHeaders: http.Header{
58+
"Content-Type": {"text/plain; charset=utf-8"},
59+
"Content-Disposition": {"attachment; filename=\"custom.txt\""},
60+
"X-Custom-Header": {"value123"},
61+
},
62+
},
63+
}
64+
65+
for _, tc := range testCases {
66+
t.Run(tc.name, func(t *testing.T) {
67+
// Arrange
68+
spy := &spyNextHandler{}
69+
logBuffer := new(bytes.Buffer)
70+
logger := slog.New(slog.NewTextHandler(logBuffer, nil))
71+
handler, err := web.FlexibleFileServer(tc.resolver, logger, spy)
72+
if err != nil {
73+
t.Fatalf("FlexibleFileServer() returned unexpected error: %v", err)
74+
}
75+
req := httptest.NewRequest("GET", "/any", nil)
76+
rr := httptest.NewRecorder()
77+
78+
// Act
79+
handler.ServeHTTP(rr, req)
80+
81+
// Assert
82+
if spy.called {
83+
t.Error("next handler was called unexpectedly on a successful request")
84+
}
85+
if rr.Code != http.StatusOK {
86+
t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK)
87+
}
88+
if body := rr.Body.String(); body != tc.expectedBody {
89+
t.Errorf("handler returned unexpected body: got %q want %q", body, tc.expectedBody)
90+
}
91+
for key, values := range tc.expectedHeaders {
92+
if got := rr.Header().Get(key); got != values[0] {
93+
t.Errorf("mismatched header %q: got %q want %q", key, got, values[0])
94+
}
95+
}
96+
if logBuffer.Len() > 0 {
97+
t.Errorf("log buffer was written to unexpectedly: %s", logBuffer.String())
98+
}
99+
})
100+
}
101+
}
102+
103+
// TestFlexibleFileServer_ErrorHandling tests various error scenarios to ensure that
104+
// the handler logs the error and correctly passes control to the next handler.
105+
func TestFlexibleFileServer_ErrorHandling(t *testing.T) {
106+
resolverErr := errors.New("resolver access denied")
107+
108+
testCases := []struct {
109+
name string
110+
resolver func(r *http.Request) (fs.File, http.Header, error)
111+
expectedLog string
112+
}{
113+
{
114+
name: "Resolver returns an error",
115+
resolver: func(r *http.Request) (fs.File, http.Header, error) {
116+
return nil, nil, resolverErr
117+
},
118+
expectedLog: "resolver error",
119+
},
120+
{
121+
name: "Resolver returns file that fails to stat",
122+
resolver: func(r *http.Request) (fs.File, http.Header, error) {
123+
return statErrorFile{}, nil, nil
124+
},
125+
expectedLog: "failed to stat file",
126+
},
127+
{
128+
name: "Resolver returns a non-seekable file",
129+
resolver: func(r *http.Request) (fs.File, http.Header, error) {
130+
fileContent := "cannot seek this"
131+
file := &nonSeekableFile{
132+
Reader: strings.NewReader(fileContent),
133+
info: staticFileInfo{
134+
name: "noseek.dat",
135+
size: int64(len(fileContent)),
136+
modTime: time.Now(),
137+
},
138+
}
139+
return file, nil, nil
140+
},
141+
expectedLog: "file is not seekable",
142+
},
143+
}
144+
145+
for _, tc := range testCases {
146+
t.Run(tc.name, func(t *testing.T) {
147+
// Arrange
148+
spy := &spyNextHandler{}
149+
logBuffer := new(bytes.Buffer)
150+
logger := slog.New(slog.NewTextHandler(logBuffer, nil))
151+
handler, err := web.FlexibleFileServer(tc.resolver, logger, spy)
152+
if err != nil {
153+
t.Fatalf("FlexibleFileServer() returned unexpected error: %v", err)
154+
}
155+
req := httptest.NewRequest("GET", "/any", nil)
156+
rr := httptest.NewRecorder()
157+
158+
// Act
159+
handler.ServeHTTP(rr, req)
160+
161+
// Assert
162+
if !spy.called {
163+
t.Error("next handler was not called on error")
164+
}
165+
if rr.Code != http.StatusTeapot {
166+
t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusTeapot)
167+
}
168+
if !strings.Contains(logBuffer.String(), tc.expectedLog) {
169+
t.Errorf("log message %q does not contain expected text %q", logBuffer.String(), tc.expectedLog)
170+
}
171+
})
172+
}
173+
}
174+
175+
// TestFlexibleFileServer_ConstructorValidation ensures that the FlexibleFileServer
176+
// function returns an error when provided with invalid (nil) arguments.
177+
func TestFlexibleFileServer_ConstructorValidation(t *testing.T) {
178+
dummyResolver := func(r *http.Request) (fs.File, http.Header, error) { return nil, nil, nil }
179+
dummyHandler := http.NotFoundHandler()
180+
dummyLogger := slog.New(slog.NewTextHandler(io.Discard, nil))
181+
182+
testCases := []struct {
183+
name string
184+
resolver func(r *http.Request) (fs.File, http.Header, error)
185+
next http.Handler
186+
wantErr bool
187+
}{
188+
{"Valid arguments", dummyResolver, dummyHandler, false},
189+
{"Error on nil resolver", nil, dummyHandler, true},
190+
{"Error on nil next handler", dummyResolver, nil, true},
191+
}
192+
193+
for _, tc := range testCases {
194+
t.Run(tc.name, func(t *testing.T) {
195+
// Act
196+
_, err := web.FlexibleFileServer(tc.resolver, dummyLogger, tc.next)
197+
198+
// Assert
199+
if (err != nil) != tc.wantErr {
200+
t.Errorf("FlexibleFileServer() error = %v, wantErr %v", err, tc.wantErr)
201+
}
202+
})
203+
}
204+
}
205+
206+
// spyNextHandler is a test helper that implements http.Handler. It is used to
207+
// verify that the middleware under test correctly calls the next handler in the
208+
// chain when an error is encountered.
209+
type spyNextHandler struct {
210+
called bool
211+
}
212+
213+
// ServeHTTP records that it was called and writes a unique status code
214+
// to allow assertions on which handler ultimately handled the request.
215+
func (n *spyNextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
216+
n.called = true
217+
w.WriteHeader(http.StatusTeapot)
218+
}
219+
220+
// --- Mock fs.File Implementations for Error Path Testing ---
221+
222+
// staticFileInfo provides a minimal implementation of fs.FileInfo for use in tests.
223+
type staticFileInfo struct {
224+
name string
225+
size int64
226+
modTime time.Time
227+
}
228+
229+
func (s staticFileInfo) Name() string { return s.name }
230+
func (s staticFileInfo) Size() int64 { return s.size }
231+
func (s staticFileInfo) Mode() fs.FileMode { return 0 }
232+
func (s staticFileInfo) ModTime() time.Time { return s.modTime }
233+
func (s staticFileInfo) IsDir() bool { return false }
234+
func (s staticFileInfo) Sys() any { return nil }
235+
236+
// statErrorFile is a mock fs.File that always returns an error from its Stat method.
237+
// This is used to test the error handling path when file.Stat() fails.
238+
type statErrorFile struct{}
239+
240+
func (f statErrorFile) Stat() (fs.FileInfo, error) {
241+
return nil, errors.New("mock stat error")
242+
}
243+
244+
// Close is implemented to satisfy the fs.File interface and prevent panics
245+
// from defer file.Close() calls in the handler.
246+
func (f statErrorFile) Close() error { return nil }
247+
248+
// Read is implemented to satisfy the fs.File interface.
249+
func (f statErrorFile) Read(p []byte) (n int, err error) { return 0, io.EOF }
250+
251+
// nonSeekableFile is a test utility that wraps an io.Reader to implement fs.File
252+
// but explicitly does not implement io.Seeker. This is used to test the handler's
253+
// error path when a file cannot be seeked, as required by http.ServeContent.
254+
type nonSeekableFile struct {
255+
io.Reader
256+
info fs.FileInfo
257+
}
258+
259+
func (f *nonSeekableFile) Stat() (fs.FileInfo, error) { return f.info, nil }
260+
func (f *nonSeekableFile) Close() error { return nil }

0 commit comments

Comments
 (0)