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