From 29778b086c42d966f996665384c830706965cb08 Mon Sep 17 00:00:00 2001 From: Will McCutchen <will@mccutch.org> Date: Sat, 27 May 2017 17:28:23 -0700 Subject: [PATCH] Add /cache and /cache/N --- httpbin/handlers.go | 42 +++++++++++++++++++++ httpbin/handlers_test.go | 79 ++++++++++++++++++++++++++++++++++++++++ httpbin/helpers.go | 6 +++ httpbin/httpbin.go | 3 ++ 4 files changed, 130 insertions(+) diff --git a/httpbin/handlers.go b/httpbin/handlers.go index baf8dc2..d4d42c5 100644 --- a/httpbin/handlers.go +++ b/httpbin/handlers.go @@ -567,3 +567,45 @@ Disallow: /deny func (h *HTTPBin) Deny(w http.ResponseWriter, r *http.Request) { writeResponse(w, http.StatusOK, "text/plain", []byte(`YOU SHOULDN'T BE HERE`)) } + +// Cache returns a 304 if an If-Modified-Since or an If-None-Match header is +// present, otherwise returns the same response as Get. +func (h *HTTPBin) Cache(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("If-Modified-Since") != "" || r.Header.Get("If-None-Match") != "" { + w.WriteHeader(http.StatusNotModified) + return + } + + // Did we get an additional /cache/N path parameter? If so, validate it + // and set the Cache-Control header. + parts := strings.Split(r.URL.Path, "/") + if len(parts) == 3 { + seconds, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + w.Header().Add("Cache-Control", fmt.Sprintf("public, max-age=%d", seconds)) + } + + lastModified := time.Now().Format(time.RFC1123) + w.Header().Add("Last-Modified", lastModified) + w.Header().Add("ETag", sha1hash(lastModified)) + h.Get(w, r) +} + +// CacheControl sets a Cache-Control header for N seconds for /cache/N requests +func (h *HTTPBin) CacheControl(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + if len(parts) != 3 { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + seconds, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + + w.Header().Add("Cache-Control", fmt.Sprintf("public, max-age=%d", seconds)) + h.Get(w, r) +} diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go index 1a23a14..d73234d 100644 --- a/httpbin/handlers_test.go +++ b/httpbin/handlers_test.go @@ -1561,3 +1561,82 @@ func TestDeny(t *testing.T) { assertContentType(t, w, "text/plain") assertBodyContains(t, w, `YOU SHOULDN'T BE HERE`) } + +func TestCache(t *testing.T) { + t.Run("ok_no_cache", func(t *testing.T) { + url := "/cache" + r, _ := http.NewRequest("GET", url, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + assertStatusCode(t, w, http.StatusOK) + assertContentType(t, w, jsonContentType) + + lastModified := w.Header().Get("Last-Modified") + if lastModified == "" { + t.Fatalf("did get Last-Modified header") + } + + etag := w.Header().Get("ETag") + if etag != sha1hash(lastModified) { + t.Fatalf("expected ETag header %v, got %v", sha1hash(lastModified), etag) + } + + var resp *getResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("failed to unmarshal body %s from JSON: %s", w.Body, err) + } + }) + + // Note: httpbin rejects these requests with invalid range headers, but the + // go stdlib just ignores them. + var tests = []struct { + headerKey string + headerVal string + }{ + {"If-None-Match", "my-custom-etag"}, + {"If-Modified-Since", "my-custom-date"}, + } + for _, test := range tests { + t.Run(fmt.Sprintf("ok_cache/%s", test.headerKey), func(t *testing.T) { + r, _ := http.NewRequest("GET", "/cache", nil) + r.Header.Add(test.headerKey, test.headerVal) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + assertStatusCode(t, w, http.StatusNotModified) + }) + } +} + +func TestCacheControl(t *testing.T) { + t.Run("ok_cache_control", func(t *testing.T) { + url := "/cache/60" + r, _ := http.NewRequest("GET", url, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + assertStatusCode(t, w, http.StatusOK) + assertContentType(t, w, jsonContentType) + assertHeader(t, w, "Cache-Control", "public, max-age=60") + }) + + // Note: httpbin rejects these requests with invalid range headers, but the + // go stdlib just ignores them. + var badTests = []struct { + url string + expectedStatus int + }{ + {"/cache/60/foo", http.StatusNotFound}, + {"/cache/foo", http.StatusBadRequest}, + {"/cache/3.14", http.StatusBadRequest}, + } + for _, test := range badTests { + t.Run(fmt.Sprintf("bad/%s", test.url), func(t *testing.T) { + r, _ := http.NewRequest("GET", test.url, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + assertStatusCode(t, w, test.expectedStatus) + }) + } +} diff --git a/httpbin/helpers.go b/httpbin/helpers.go index eece1e3..682996e 100644 --- a/httpbin/helpers.go +++ b/httpbin/helpers.go @@ -1,6 +1,7 @@ package httpbin import ( + "crypto/sha1" "encoding/json" "errors" "fmt" @@ -183,3 +184,8 @@ func (s *syntheticReadSeeker) Seek(offset int64, whence int) (int64, error) { return s.offset, nil } + +func sha1hash(input string) string { + h := sha1.New() + return fmt.Sprintf("%x", h.Sum([]byte(input))) +} diff --git a/httpbin/httpbin.go b/httpbin/httpbin.go index 674522f..679c471 100644 --- a/httpbin/httpbin.go +++ b/httpbin/httpbin.go @@ -126,6 +126,9 @@ func (h *HTTPBin) Handler() http.Handler { mux.HandleFunc("/robots.txt", h.Robots) mux.HandleFunc("/deny", h.Deny) + mux.HandleFunc("/cache", h.Cache) + mux.HandleFunc("/cache/", h.CacheControl) + // Make sure our ServeMux doesn't "helpfully" redirect these invalid // endpoints by adding a trailing slash. See the ServeMux docs for more // info: https://golang.org/pkg/net/http/#ServeMux -- GitLab