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