Skip to content

Commit c2bb197

Browse files
authored
refactor(httputil): generalise range support via ServeCacheHit hooks (#359)
Add a WithResponseDecorator option and a bytes-served return to ServeCacheHit so endpoints needing custom headers or negotiation can reuse the shared range/conditional machinery instead of reimplementing 206/416/304/412 handling. Refactor the git snapshot and bundle serve paths onto it: both now forward ConditionalOptions to Open and serve via ServeCacheHit, deleting the bespoke range/conditional code and giving bundles range support. Snapshot freshen metadata is injected through the decorator on both full and ranged responses. Drop the now-unused RangeOptions helper.
1 parent 232564b commit c2bb197

5 files changed

Lines changed: 215 additions & 138 deletions

File tree

internal/httputil/conditional.go

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,23 +32,6 @@ func ConditionalOptions(r *http.Request) []client.RequestOption {
3232
return opts
3333
}
3434

35-
// RangeOptions extracts only the Range/If-Range options from r, for callers
36-
// that evaluate If-Match/If-None-Match separately (e.g. via CheckConditionals)
37-
// and so must not double-handle them by forwarding the full set to Open.
38-
// If-Range is only meaningful alongside Range, so it is dropped when Range is
39-
// absent.
40-
func RangeOptions(r *http.Request) []client.RequestOption {
41-
v := r.Header.Get("Range")
42-
if v == "" {
43-
return nil
44-
}
45-
opts := []client.RequestOption{func(o *client.RequestOptions) { o.Range = v }}
46-
if ir := r.Header.Get("If-Range"); ir != "" {
47-
opts = append(opts, client.IfRange(ir))
48-
}
49-
return opts
50-
}
51-
5235
// CheckConditionals evaluates RFC 7232 If-Match and If-None-Match precondition
5336
// headers on r against etag. It returns 0 when all preconditions pass,
5437
// otherwise the HTTP status the caller should send: 412 Precondition Failed for
@@ -66,42 +49,74 @@ func CheckConditionals(r *http.Request, etag string) int {
6649
}
6750
}
6851

52+
// ServeOption configures ServeCacheHit.
53+
type ServeOption func(*serveConfig)
54+
55+
type serveConfig struct {
56+
decorate func(http.ResponseWriter, http.Header)
57+
}
58+
59+
// WithResponseDecorator registers fn to run after the stored headers have been
60+
// copied to the response but before the status line is written, on the 200, 206
61+
// and 416 paths. It lets endpoints that delegate range/conditional resolution to
62+
// the cache still override or augment the response (e.g. force a Content-Type or
63+
// advertise endpoint-specific metadata) uniformly across full and partial
64+
// responses. fn is given the stored headers (so it can branch on, say, a
65+
// Content-Range) and must not write the body or call WriteHeader.
66+
func WithResponseDecorator(fn func(w http.ResponseWriter, stored http.Header)) ServeOption {
67+
return func(c *serveConfig) { c.decorate = fn }
68+
}
69+
6970
// ServeCacheHit writes the outcome of a cache Open to w. headers and body are
7071
// the Open return values and openErr its error. It handles the success and
7172
// conditional cases: a nil error streams the body (always closing it), a
7273
// satisfied If-None-Match (ErrNotModified) writes 304 with the stored headers,
7374
// and a failed If-Match (ErrPreconditionFailed) writes 412. It returns
7475
// handled=false for any other error (e.g. os.ErrNotExist) so the caller can map
75-
// it to its own status.
76-
func ServeCacheHit(w http.ResponseWriter, headers http.Header, body io.ReadCloser, openErr error) (handled bool, err error) {
76+
// it to its own status, and n is the number of body bytes written (0 for
77+
// bodiless responses).
78+
func ServeCacheHit(w http.ResponseWriter, headers http.Header, body io.ReadCloser, openErr error, opts ...ServeOption) (handled bool, n int64, err error) {
79+
var cfg serveConfig
80+
for _, opt := range opts {
81+
opt(&cfg)
82+
}
83+
decorate := func() {
84+
if cfg.decorate != nil {
85+
cfg.decorate(w, headers)
86+
}
87+
}
88+
7789
switch {
7890
case openErr == nil:
7991
maps.Copy(w.Header(), headers)
8092
w.Header().Set("Accept-Ranges", "bytes")
93+
decorate()
8194
// A Content-Range set by the cache signals a satisfied byte range.
8295
if headers.Get("Content-Range") != "" {
8396
w.WriteHeader(http.StatusPartialContent)
8497
}
85-
_, copyErr := io.Copy(w, body)
86-
return true, errors.Wrap(errors.Join(copyErr, body.Close()), "serve cache hit")
98+
var copyErr error
99+
n, copyErr = io.Copy(w, body)
100+
return true, n, errors.Wrap(errors.Join(copyErr, body.Close()), "serve cache hit")
87101

88102
case errors.Is(openErr, client.ErrNotModified):
89103
maps.Copy(w.Header(), headers)
90104
w.WriteHeader(http.StatusNotModified)
91-
return true, nil
105+
return true, 0, nil
92106

93107
case errors.Is(openErr, client.ErrPreconditionFailed):
94108
w.WriteHeader(http.StatusPreconditionFailed)
95-
return true, nil
109+
return true, 0, nil
96110

97111
case errors.Is(openErr, client.ErrRangeNotSatisfiable):
98112
maps.Copy(w.Header(), headers)
99113
w.Header().Set("Accept-Ranges", "bytes")
114+
decorate()
100115
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
101-
return true, nil
116+
return true, 0, nil
102117

103118
default:
104-
return false, nil
119+
return false, 0, nil
105120
}
106121
}
107122

internal/httputil/conditional_test.go

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,16 @@ func TestServeCacheHit(t *testing.T) {
8383
headers := cacheHeaders([2]string{"Content-Type", "text/plain"})
8484
w := httptest.NewRecorder()
8585

86-
handled, err := httputil.ServeCacheHit(w, headers, body, nil)
86+
handled, n, err := httputil.ServeCacheHit(w, headers, body, nil)
8787
assert.True(t, handled)
88+
assert.Equal(t, int64(len("payload")), n)
8889
assert.NoError(t, err)
8990
assert.True(t, body.closed)
9091

9192
resp := w.Result()
9293
defer resp.Body.Close()
9394
assert.Equal(t, http.StatusOK, resp.StatusCode)
95+
assert.Equal(t, "bytes", resp.Header.Get("Accept-Ranges"))
9496
assert.Equal(t, testETag, resp.Header.Get("ETag"))
9597
assert.Equal(t, "text/plain", resp.Header.Get("Content-Type"))
9698
data, _ := io.ReadAll(resp.Body)
@@ -101,8 +103,9 @@ func TestServeCacheHit(t *testing.T) {
101103
headers := cacheHeaders()
102104
w := httptest.NewRecorder()
103105

104-
handled, err := httputil.ServeCacheHit(w, headers, nil, client.ErrNotModified)
106+
handled, n, err := httputil.ServeCacheHit(w, headers, nil, client.ErrNotModified)
105107
assert.True(t, handled)
108+
assert.Equal(t, int64(0), n)
106109
assert.NoError(t, err)
107110

108111
resp := w.Result()
@@ -116,20 +119,96 @@ func TestServeCacheHit(t *testing.T) {
116119
t.Run("PreconditionFailed", func(t *testing.T) {
117120
w := httptest.NewRecorder()
118121

119-
handled, err := httputil.ServeCacheHit(w, nil, nil, client.ErrPreconditionFailed)
122+
handled, n, err := httputil.ServeCacheHit(w, nil, nil, client.ErrPreconditionFailed)
120123
assert.True(t, handled)
124+
assert.Equal(t, int64(0), n)
121125
assert.NoError(t, err)
122126

123127
resp := w.Result()
124128
defer resp.Body.Close()
125129
assert.Equal(t, http.StatusPreconditionFailed, resp.StatusCode)
126130
})
127131

132+
t.Run("PartialContentWhenRanged", func(t *testing.T) {
133+
body := &trackingReader{Reader: strings.NewReader("2345")}
134+
headers := cacheHeaders([2]string{"Content-Range", "bytes 2-5/10"})
135+
w := httptest.NewRecorder()
136+
137+
handled, n, err := httputil.ServeCacheHit(w, headers, body, nil)
138+
assert.True(t, handled)
139+
assert.Equal(t, int64(4), n)
140+
assert.NoError(t, err)
141+
assert.True(t, body.closed)
142+
143+
resp := w.Result()
144+
defer resp.Body.Close()
145+
assert.Equal(t, http.StatusPartialContent, resp.StatusCode)
146+
assert.Equal(t, "bytes 2-5/10", resp.Header.Get("Content-Range"))
147+
assert.Equal(t, "bytes", resp.Header.Get("Accept-Ranges"))
148+
})
149+
150+
t.Run("RangeNotSatisfiable", func(t *testing.T) {
151+
headers := cacheHeaders([2]string{"Content-Range", "bytes */10"})
152+
w := httptest.NewRecorder()
153+
154+
handled, n, err := httputil.ServeCacheHit(w, headers, nil, client.ErrRangeNotSatisfiable)
155+
assert.True(t, handled)
156+
assert.Equal(t, int64(0), n)
157+
assert.NoError(t, err)
158+
159+
resp := w.Result()
160+
defer resp.Body.Close()
161+
assert.Equal(t, http.StatusRequestedRangeNotSatisfiable, resp.StatusCode)
162+
assert.Equal(t, "bytes */10", resp.Header.Get("Content-Range"))
163+
assert.Equal(t, "bytes", resp.Header.Get("Accept-Ranges"))
164+
})
165+
166+
t.Run("DecoratorRunsOnFullPartialAndUnsatisfiable", func(t *testing.T) {
167+
// The decorator must run before the status is written on the 200, 206 and
168+
// 416 paths, but not on the bodiless 304/412 paths.
169+
cases := []struct {
170+
name string
171+
headers http.Header
172+
body io.ReadCloser
173+
openErr error
174+
wantRun bool
175+
wantStatus int
176+
}{
177+
{name: "Full", headers: cacheHeaders(), body: &trackingReader{Reader: strings.NewReader("x")}, wantRun: true, wantStatus: http.StatusOK},
178+
{name: "Partial", headers: cacheHeaders([2]string{"Content-Range", "bytes 0-0/2"}), body: &trackingReader{Reader: strings.NewReader("x")}, wantRun: true, wantStatus: http.StatusPartialContent},
179+
{name: "Unsatisfiable", headers: cacheHeaders([2]string{"Content-Range", "bytes */2"}), openErr: client.ErrRangeNotSatisfiable, wantRun: true, wantStatus: http.StatusRequestedRangeNotSatisfiable},
180+
{name: "NotModified", headers: cacheHeaders(), openErr: client.ErrNotModified, wantRun: false, wantStatus: http.StatusNotModified},
181+
{name: "PreconditionFailed", openErr: client.ErrPreconditionFailed, wantRun: false, wantStatus: http.StatusPreconditionFailed},
182+
}
183+
for _, tc := range cases {
184+
t.Run(tc.name, func(t *testing.T) {
185+
w := httptest.NewRecorder()
186+
var ran bool
187+
decorate := func(rw http.ResponseWriter, _ http.Header) {
188+
ran = true
189+
rw.Header().Set("X-Decorated", "yes")
190+
}
191+
handled, _, err := httputil.ServeCacheHit(w, tc.headers, tc.body, tc.openErr, httputil.WithResponseDecorator(decorate))
192+
assert.True(t, handled)
193+
assert.NoError(t, err)
194+
assert.Equal(t, tc.wantRun, ran)
195+
196+
resp := w.Result()
197+
defer resp.Body.Close()
198+
assert.Equal(t, tc.wantStatus, resp.StatusCode)
199+
if tc.wantRun {
200+
assert.Equal(t, "yes", resp.Header.Get("X-Decorated"))
201+
}
202+
})
203+
}
204+
})
205+
128206
t.Run("NotHandledForOtherError", func(t *testing.T) {
129207
w := httptest.NewRecorder()
130208

131-
handled, err := httputil.ServeCacheHit(w, nil, nil, os.ErrNotExist)
209+
handled, n, err := httputil.ServeCacheHit(w, nil, nil, os.ErrNotExist)
132210
assert.False(t, handled)
211+
assert.Equal(t, int64(0), n)
133212
assert.NoError(t, err)
134213
assert.Equal(t, http.StatusOK, w.Result().StatusCode) // response untouched
135214
})

internal/strategy/apiv1.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func (d *APIV1) getObject(w http.ResponseWriter, r *http.Request) {
8282

8383
namespacedCache := d.cache.Namespace(namespace)
8484
cr, headers, err := namespacedCache.Open(r.Context(), key, httputil.ConditionalOptions(r)...)
85-
if handled, serveErr := httputil.ServeCacheHit(w, headers, cr, err); handled {
85+
if handled, _, serveErr := httputil.ServeCacheHit(w, headers, cr, err); handled {
8686
if serveErr != nil {
8787
d.logger.Error("Failed to serve cache object", "error", serveErr, "key", key)
8888
}

0 commit comments

Comments
 (0)