-
Notifications
You must be signed in to change notification settings - Fork 6
refactor(httputil): consolidate conditional-request serving #340
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| package httputil | ||
|
|
||
| import ( | ||
| "io" | ||
| "maps" | ||
| "net/http" | ||
| "strings" | ||
|
|
||
| "github.com/alecthomas/errors" | ||
| ) | ||
|
|
||
| // ETagHeader is the HTTP header used to carry an object's entity tag. | ||
| const ETagHeader = "ETag" | ||
|
|
||
| // CheckConditionals evaluates RFC 7232 If-Match and If-None-Match precondition | ||
| // headers against the stored ETag. It returns 0 when all preconditions pass, | ||
| // otherwise the HTTP status code the caller should send: 412 Precondition | ||
| // Failed for a failed If-Match, or 304 Not Modified for a satisfied | ||
| // If-None-Match. | ||
| func CheckConditionals(r *http.Request, etag string) int { | ||
| if ifMatch := r.Header.Get("If-Match"); ifMatch != "" { | ||
| if etag == "" || !etagListMatches(ifMatch, etag) { | ||
| return http.StatusPreconditionFailed | ||
| } | ||
| } | ||
| if ifNoneMatch := r.Header.Get("If-None-Match"); ifNoneMatch != "" { | ||
| if etag != "" && etagListMatches(ifNoneMatch, etag) { | ||
| return http.StatusNotModified | ||
| } | ||
| } | ||
| return 0 | ||
| } | ||
|
|
||
| // etagListMatches reports whether etag matches an If-Match / If-None-Match | ||
| // header value, which may be a comma-separated list of ETags or the "*" | ||
| // wildcard. Stored ETags are always strong, so weak comparison is not required. | ||
| func etagListMatches(headerValue, etag string) bool { | ||
| for candidate := range strings.SplitSeq(headerValue, ",") { | ||
| candidate = strings.TrimSpace(candidate) | ||
| if candidate == "*" || candidate == etag { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| // ServeCacheHit serves a cache hit over HTTP. It copies the stored headers onto | ||
| // the response, evaluates conditional request preconditions against the stored | ||
| // ETag, and either short-circuits with a 304/412 status or streams the body. | ||
| // The body is always closed. | ||
| // | ||
| // This consolidates the validator-aware serving path shared by handlers that | ||
| // return a single cached object (e.g. the API and the generic caching handler). | ||
| func ServeCacheHit(w http.ResponseWriter, r *http.Request, headers http.Header, body io.ReadCloser) error { | ||
| maps.Copy(w.Header(), headers) | ||
| if status := CheckConditionals(r, headers.Get(ETagHeader)); status != 0 { | ||
| w.WriteHeader(status) | ||
| return errors.WithStack(body.Close()) | ||
| } | ||
| _, copyErr := io.Copy(w, body) | ||
| return errors.Wrap(errors.Join(copyErr, body.Close()), "serve cache hit") | ||
| } | ||
|
|
||
| // ServeCacheStat answers a metadata-only (HEAD) request from stored headers. It | ||
| // copies the headers onto the response and writes the status determined by the | ||
| // conditional request preconditions, defaulting to 200 OK. | ||
| func ServeCacheStat(w http.ResponseWriter, r *http.Request, headers http.Header) { | ||
| maps.Copy(w.Header(), headers) | ||
| status := CheckConditionals(r, headers.Get(ETagHeader)) | ||
| if status == 0 { | ||
| status = http.StatusOK | ||
| } | ||
| w.WriteHeader(status) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| package httputil_test | ||
|
|
||
| import ( | ||
| "io" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/alecthomas/assert/v2" | ||
|
|
||
| "github.com/block/cachew/internal/httputil" | ||
| ) | ||
|
|
||
| const testETag = `"abc123"` | ||
|
|
||
| func cacheHeaders(extra ...[2]string) http.Header { | ||
| h := http.Header{} | ||
| h.Set(httputil.ETagHeader, testETag) | ||
| for _, kv := range extra { | ||
| h.Set(kv[0], kv[1]) | ||
| } | ||
| return h | ||
| } | ||
|
|
||
| func newRequest(t *testing.T, ifMatch, ifNoneMatch string) *http.Request { | ||
| t.Helper() | ||
| r := httptest.NewRequest(http.MethodGet, "/", nil) | ||
| if ifMatch != "" { | ||
| r.Header.Set("If-Match", ifMatch) | ||
| } | ||
| if ifNoneMatch != "" { | ||
| r.Header.Set("If-None-Match", ifNoneMatch) | ||
| } | ||
| return r | ||
| } | ||
|
|
||
| func TestCheckConditionals(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| ifMatch string | ||
| ifNoneMatch string | ||
| etag string | ||
| want int | ||
| }{ | ||
| {name: "NoConditions", etag: testETag, want: 0}, | ||
| {name: "IfMatchExact", ifMatch: testETag, etag: testETag, want: 0}, | ||
| {name: "IfMatchWildcard", ifMatch: "*", etag: testETag, want: 0}, | ||
| {name: "IfMatchList", ifMatch: `"other", ` + testETag, etag: testETag, want: 0}, | ||
| {name: "IfMatchMismatch", ifMatch: `"other"`, etag: testETag, want: http.StatusPreconditionFailed}, | ||
| {name: "IfMatchNoETag", ifMatch: "*", etag: "", want: http.StatusPreconditionFailed}, | ||
| {name: "IfNoneMatchExact", ifNoneMatch: testETag, etag: testETag, want: http.StatusNotModified}, | ||
| {name: "IfNoneMatchWildcard", ifNoneMatch: "*", etag: testETag, want: http.StatusNotModified}, | ||
| {name: "IfNoneMatchList", ifNoneMatch: `"other", ` + testETag, etag: testETag, want: http.StatusNotModified}, | ||
| {name: "IfNoneMatchMismatch", ifNoneMatch: `"other"`, etag: testETag, want: 0}, | ||
| {name: "IfNoneMatchNoETag", ifNoneMatch: "*", etag: "", want: 0}, | ||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| got := httputil.CheckConditionals(newRequest(t, tt.ifMatch, tt.ifNoneMatch), tt.etag) | ||
| assert.Equal(t, tt.want, got) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // trackingReader records whether Close was called, so tests can assert the body | ||
| // is always released. | ||
| type trackingReader struct { | ||
| io.Reader | ||
| closed bool | ||
| } | ||
|
|
||
| func (t *trackingReader) Close() error { | ||
| t.closed = true | ||
| return nil | ||
| } | ||
|
|
||
| func TestServeCacheHit(t *testing.T) { | ||
| t.Run("StreamsBodyWhenFresh", func(t *testing.T) { | ||
| body := &trackingReader{Reader: strings.NewReader("payload")} | ||
| headers := cacheHeaders([2]string{"Content-Type", "text/plain"}) | ||
| w := httptest.NewRecorder() | ||
|
|
||
| err := httputil.ServeCacheHit(w, newRequest(t, "", ""), headers, body) | ||
| assert.NoError(t, err) | ||
| assert.True(t, body.closed) | ||
|
|
||
| resp := w.Result() | ||
| defer resp.Body.Close() | ||
| assert.Equal(t, http.StatusOK, resp.StatusCode) | ||
| assert.Equal(t, testETag, resp.Header.Get("ETag")) | ||
| assert.Equal(t, "text/plain", resp.Header.Get("Content-Type")) | ||
| data, _ := io.ReadAll(resp.Body) | ||
| assert.Equal(t, "payload", string(data)) | ||
| }) | ||
|
|
||
| t.Run("NotModifiedSkipsBody", func(t *testing.T) { | ||
| body := &trackingReader{Reader: strings.NewReader("payload")} | ||
| headers := cacheHeaders() | ||
| w := httptest.NewRecorder() | ||
|
|
||
| err := httputil.ServeCacheHit(w, newRequest(t, "", testETag), headers, body) | ||
| assert.NoError(t, err) | ||
| assert.True(t, body.closed) | ||
|
|
||
| resp := w.Result() | ||
| defer resp.Body.Close() | ||
| assert.Equal(t, http.StatusNotModified, resp.StatusCode) | ||
| data, _ := io.ReadAll(resp.Body) | ||
| assert.Equal(t, "", string(data)) | ||
| }) | ||
|
|
||
| t.Run("PreconditionFailedSkipsBody", func(t *testing.T) { | ||
| body := &trackingReader{Reader: strings.NewReader("payload")} | ||
| headers := cacheHeaders() | ||
| w := httptest.NewRecorder() | ||
|
|
||
| err := httputil.ServeCacheHit(w, newRequest(t, `"other"`, ""), headers, body) | ||
| assert.NoError(t, err) | ||
| assert.True(t, body.closed) | ||
|
|
||
| resp := w.Result() | ||
| defer resp.Body.Close() | ||
| assert.Equal(t, http.StatusPreconditionFailed, resp.StatusCode) | ||
| }) | ||
| } | ||
|
|
||
| func TestServeCacheStat(t *testing.T) { | ||
| t.Run("OKWhenFresh", func(t *testing.T) { | ||
| headers := cacheHeaders() | ||
| w := httptest.NewRecorder() | ||
|
|
||
| httputil.ServeCacheStat(w, newRequest(t, "", ""), headers) | ||
|
|
||
| resp := w.Result() | ||
| defer resp.Body.Close() | ||
| assert.Equal(t, http.StatusOK, resp.StatusCode) | ||
| assert.Equal(t, testETag, resp.Header.Get("ETag")) | ||
| }) | ||
|
|
||
| t.Run("NotModified", func(t *testing.T) { | ||
| headers := cacheHeaders() | ||
| w := httptest.NewRecorder() | ||
|
|
||
| httputil.ServeCacheStat(w, newRequest(t, "", testETag), headers) | ||
|
|
||
| resp := w.Result() | ||
| defer resp.Body.Close() | ||
| assert.Equal(t, http.StatusNotModified, resp.StatusCode) | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a cached object has
Content-Lengthand anIf-Matchrequest fails, this branch writes a 412 after copying the object's headers but sends no body. Unlike 304, Go does not suppressContent-Lengthfor 412 responses, so clients can hang or fail with an unexpected EOF waiting for the advertised cached-object bytes. Strip or replace entity headers before writing the 412 response.Useful? React with 👍 / 👎.