From c9551371f5323a63ebce8de2f4ec756a9fe3eae4 Mon Sep 17 00:00:00 2001
From: Will McCutchen <will@mccutch.org>
Date: Fri, 26 May 2017 17:31:22 -0700
Subject: [PATCH] Add /range

---
 httpbin/handlers.go      |  33 +++++++++
 httpbin/handlers_test.go | 140 +++++++++++++++++++++++++++++++++++++++
 httpbin/helpers.go       |  48 ++++++++++++++
 httpbin/httpbin.go       |   1 +
 4 files changed, 222 insertions(+)

diff --git a/httpbin/handlers.go b/httpbin/handlers.go
index 61b7e72..32b6654 100644
--- a/httpbin/handlers.go
+++ b/httpbin/handlers.go
@@ -516,3 +516,36 @@ func (h *HTTPBin) Drip(w http.ResponseWriter, r *http.Request) {
 		<-time.After(pause)
 	}
 }
+
+// Range returns up to N bytes, with support for HTTP Range requests.
+//
+// This departs from httpbin by not supporting the chunk_size or duration
+// parameters.
+func (h *HTTPBin) Range(w http.ResponseWriter, r *http.Request) {
+	parts := strings.Split(r.URL.Path, "/")
+	if len(parts) != 3 {
+		http.Error(w, "Not found", http.StatusNotFound)
+		return
+	}
+
+	numBytes, err := strconv.ParseInt(parts[2], 10, 64)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	w.Header().Add("ETag", fmt.Sprintf("range%d", numBytes))
+	w.Header().Add("Accept-Ranges", "bytes")
+
+	if numBytes <= 0 || numBytes > h.options.MaxMemory {
+		http.Error(w, "Invalid number of bytes", http.StatusBadRequest)
+		return
+	}
+
+	content := &syntheticReadSeeker{
+		numBytes:    numBytes,
+		byteFactory: func(offset int64) byte { return byte(97 + (offset % 26)) },
+	}
+	var modtime time.Time
+	http.ServeContent(w, r, "", modtime, content)
+}
diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go
index ba86ffc..3e80298 100644
--- a/httpbin/handlers_test.go
+++ b/httpbin/handlers_test.go
@@ -53,6 +53,13 @@ func assertBodyContains(t *testing.T, w *httptest.ResponseRecorder, needle strin
 	}
 }
 
+func assertBodyEquals(t *testing.T, w *httptest.ResponseRecorder, want string) {
+	have := w.Body.String()
+	if want != have {
+		t.Fatalf("expected body = %v, got %v", want, have)
+	}
+}
+
 func TestNewHTTPBin__NilOptions(t *testing.T) {
 	h := NewHTTPBin(nil)
 	if h.options.MaxMemory != 0 {
@@ -1394,3 +1401,136 @@ func TestDrip(t *testing.T) {
 		})
 	}
 }
+
+func TestRange(t *testing.T) {
+	t.Run("ok_no_range", func(t *testing.T) {
+		url := "/range/1234"
+		r, _ := http.NewRequest("GET", url, nil)
+		w := httptest.NewRecorder()
+		handler.ServeHTTP(w, r)
+
+		assertStatusCode(t, w, http.StatusOK)
+		assertHeader(t, w, "ETag", "range1234")
+		assertHeader(t, w, "Accept-Ranges", "bytes")
+		assertHeader(t, w, "Content-Length", "1234")
+		assertContentType(t, w, "text/plain; charset=utf-8")
+
+		if len(w.Body.String()) != 1234 {
+			t.Errorf("expected content length 1234, got %d", len(w.Body.String()))
+		}
+	})
+
+	t.Run("ok_range", func(t *testing.T) {
+		url := "/range/100"
+		r, _ := http.NewRequest("GET", url, nil)
+		r.Header.Add("Range", "bytes=10-24")
+		w := httptest.NewRecorder()
+		handler.ServeHTTP(w, r)
+
+		assertStatusCode(t, w, http.StatusPartialContent)
+		assertHeader(t, w, "ETag", "range100")
+		assertHeader(t, w, "Accept-Ranges", "bytes")
+		assertHeader(t, w, "Content-Length", "15")
+		assertHeader(t, w, "Content-Range", "bytes 10-24/100")
+		assertBodyEquals(t, w, "klmnopqrstuvwxy")
+	})
+
+	t.Run("ok_range_first_16_bytes", func(t *testing.T) {
+		url := "/range/1000"
+		r, _ := http.NewRequest("GET", url, nil)
+		r.Header.Add("Range", "bytes=0-15")
+		w := httptest.NewRecorder()
+		handler.ServeHTTP(w, r)
+
+		assertStatusCode(t, w, http.StatusPartialContent)
+		assertHeader(t, w, "ETag", "range1000")
+		assertHeader(t, w, "Accept-Ranges", "bytes")
+		assertHeader(t, w, "Content-Length", "16")
+		assertHeader(t, w, "Content-Range", "bytes 0-15/1000")
+		assertBodyEquals(t, w, "abcdefghijklmnop")
+	})
+
+	t.Run("ok_range_open_ended_last_6_bytes", func(t *testing.T) {
+		url := "/range/26"
+		r, _ := http.NewRequest("GET", url, nil)
+		r.Header.Add("Range", "bytes=20-")
+		w := httptest.NewRecorder()
+		handler.ServeHTTP(w, r)
+
+		assertStatusCode(t, w, http.StatusPartialContent)
+		assertHeader(t, w, "ETag", "range26")
+		assertHeader(t, w, "Content-Length", "6")
+		assertHeader(t, w, "Content-Range", "bytes 20-25/26")
+		assertBodyEquals(t, w, "uvwxyz")
+	})
+
+	t.Run("ok_range_suffix", func(t *testing.T) {
+		url := "/range/26"
+		r, _ := http.NewRequest("GET", url, nil)
+		r.Header.Add("Range", "bytes=-5")
+		w := httptest.NewRecorder()
+		handler.ServeHTTP(w, r)
+
+		t.Logf("headers = %v", w.HeaderMap)
+		assertStatusCode(t, w, http.StatusPartialContent)
+		assertHeader(t, w, "ETag", "range26")
+		assertHeader(t, w, "Content-Length", "5")
+		assertHeader(t, w, "Content-Range", "bytes 21-25/26")
+		assertBodyEquals(t, w, "vwxyz")
+	})
+
+	t.Run("err_range_out_of_bounds", func(t *testing.T) {
+		url := "/range/26"
+		r, _ := http.NewRequest("GET", url, nil)
+		r.Header.Add("Range", "bytes=-5")
+		w := httptest.NewRecorder()
+		handler.ServeHTTP(w, r)
+
+		assertStatusCode(t, w, http.StatusPartialContent)
+		assertHeader(t, w, "ETag", "range26")
+		assertHeader(t, w, "Content-Length", "5")
+		assertHeader(t, w, "Content-Range", "bytes 21-25/26")
+		assertBodyEquals(t, w, "vwxyz")
+	})
+
+	// Note: httpbin rejects these requests with invalid range headers, but the
+	// go stdlib just ignores them.
+	var badRangeTests = []struct {
+		url         string
+		rangeHeader string
+	}{
+		{"/range/26", "bytes=10-5"},
+		{"/range/26", "bytes=32-40"},
+		{"/range/26", "bytes=0-40"},
+	}
+	for _, test := range badRangeTests {
+		t.Run(fmt.Sprintf("ok_bad_range_header/%s", test.rangeHeader), func(t *testing.T) {
+			r, _ := http.NewRequest("GET", test.url, nil)
+			w := httptest.NewRecorder()
+			handler.ServeHTTP(w, r)
+			assertStatusCode(t, w, http.StatusOK)
+			assertBodyEquals(t, w, "abcdefghijklmnopqrstuvwxyz")
+		})
+	}
+
+	var badTests = []struct {
+		url  string
+		code int
+	}{
+		{"/range/1/foo", http.StatusNotFound},
+
+		{"/range/", http.StatusBadRequest},
+		{"/range/foo", http.StatusBadRequest},
+		{"/range/1.5", http.StatusBadRequest},
+		{"/range/-1", http.StatusBadRequest},
+	}
+
+	for _, test := range badTests {
+		t.Run("bad"+test.url, func(t *testing.T) {
+			r, _ := http.NewRequest("GET", test.url, nil)
+			w := httptest.NewRecorder()
+			handler.ServeHTTP(w, r)
+			assertStatusCode(t, w, test.code)
+		})
+	}
+}
diff --git a/httpbin/helpers.go b/httpbin/helpers.go
index 27ce179..eece1e3 100644
--- a/httpbin/helpers.go
+++ b/httpbin/helpers.go
@@ -2,7 +2,9 @@ package httpbin
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
+	"io"
 	"io/ioutil"
 	"net/http"
 	"net/url"
@@ -135,3 +137,49 @@ func parseBoundedDuration(input string, min, max time.Duration) (time.Duration,
 	}
 	return d, err
 }
+
+// syntheticReadSeeker implements the ReadSeeker interface to allow reading
+// arbitrary subsets of bytes up to a maximum size given a function for
+// generating the byte at a given offset.
+type syntheticReadSeeker struct {
+	numBytes    int64
+	offset      int64
+	byteFactory func(int64) byte
+}
+
+// Read implements the Reader interface for syntheticReadSeeker
+func (s *syntheticReadSeeker) Read(p []byte) (int, error) {
+	start := s.offset
+	end := start + int64(len(p))
+	var err error
+	if end > s.numBytes {
+		err = io.EOF
+		end = s.numBytes - start
+	}
+
+	for idx := start; idx < end; idx++ {
+		p[idx-start] = s.byteFactory(idx)
+	}
+
+	return int(end - start), err
+}
+
+// Seek implements the Seeker interface for syntheticReadSeeker
+func (s *syntheticReadSeeker) Seek(offset int64, whence int) (int64, error) {
+	switch whence {
+	case io.SeekStart:
+		s.offset = offset
+	case io.SeekCurrent:
+		s.offset += offset
+	case io.SeekEnd:
+		s.offset = s.numBytes - offset
+	default:
+		return 0, errors.New("Seek: invalid whence")
+	}
+
+	if s.offset < 0 {
+		return 0, errors.New("Seek: invalid offset")
+	}
+
+	return s.offset, nil
+}
diff --git a/httpbin/httpbin.go b/httpbin/httpbin.go
index 31d06a5..d19777a 100644
--- a/httpbin/httpbin.go
+++ b/httpbin/httpbin.go
@@ -120,6 +120,7 @@ func (h *HTTPBin) Handler() http.Handler {
 	mux.HandleFunc("/stream/", h.Stream)
 	mux.HandleFunc("/delay/", h.Delay)
 	mux.HandleFunc("/drip", h.Drip)
+	mux.HandleFunc("/range/", h.Range)
 
 	// Make sure our ServeMux doesn't "helpfully" redirect these invalid
 	// endpoints by adding a trailing slash. See the ServeMux docs for more
-- 
GitLab