From 05a30fa73f35a94721c4bbb3072a2314ea6d7cf9 Mon Sep 17 00:00:00 2001
From: Will McCutchen <will@mccutch.org>
Date: Sat, 31 Dec 2016 23:06:50 -0500
Subject: [PATCH] Add /drip

---
 httpbin/handlers.go      |  68 ++++++++++++++++++++++++++
 httpbin/handlers_test.go | 103 +++++++++++++++++++++++++++++++++++++++
 httpbin/helpers.go       |   3 +-
 httpbin/httpbin.go       |   2 +
 main.go                  |   1 +
 5 files changed, 176 insertions(+), 1 deletion(-)

diff --git a/httpbin/handlers.go b/httpbin/handlers.go
index e8a3c07..61b7e72 100644
--- a/httpbin/handlers.go
+++ b/httpbin/handlers.go
@@ -448,3 +448,71 @@ func (h *HTTPBin) Delay(w http.ResponseWriter, r *http.Request) {
 	<-time.After(delay)
 	h.RequestWithBody(w, r)
 }
+
+// Drip returns data over a duration after an optional initial delay, then
+// (optionally) returns with the given status code.
+func (h *HTTPBin) Drip(w http.ResponseWriter, r *http.Request) {
+	q := r.URL.Query()
+
+	duration := time.Duration(0)
+	delay := time.Duration(0)
+	numbytes := int64(10)
+	code := http.StatusOK
+
+	var err error
+
+	userDuration := q.Get("duration")
+	if userDuration != "" {
+		duration, err = parseBoundedDuration(userDuration, 0, h.options.MaxResponseTime)
+		if err != nil {
+			http.Error(w, "Invalid duration", http.StatusBadRequest)
+			return
+		}
+	}
+
+	userDelay := q.Get("delay")
+	if userDelay != "" {
+		delay, err = parseBoundedDuration(userDelay, 0, h.options.MaxResponseTime)
+		if err != nil {
+			http.Error(w, "Invalid delay", http.StatusBadRequest)
+			return
+		}
+	}
+
+	userNumBytes := q.Get("numbytes")
+	if userNumBytes != "" {
+		numbytes, err = strconv.ParseInt(userNumBytes, 10, 64)
+		if err != nil || numbytes <= 0 || numbytes > h.options.MaxResponseSize {
+			http.Error(w, "Invalid numbytes", http.StatusBadRequest)
+			return
+		}
+	}
+
+	userCode := q.Get("code")
+	if userCode != "" {
+		code, err = strconv.Atoi(userCode)
+		if err != nil || code < 100 || code >= 600 {
+			http.Error(w, "Invalid code", http.StatusBadRequest)
+			return
+		}
+	}
+
+	if duration+delay > h.options.MaxResponseTime {
+		http.Error(w, "Too much time", http.StatusBadRequest)
+		return
+	}
+
+	pause := duration / time.Duration(numbytes)
+
+	<-time.After(delay)
+
+	w.WriteHeader(code)
+	w.Header().Set("Content-Type", "application/octet-stream")
+
+	f := w.(http.Flusher)
+	for i := int64(0); i < numbytes; i++ {
+		w.Write([]byte("*"))
+		f.Flush()
+		<-time.After(pause)
+	}
+}
diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go
index 07036a3..ba86ffc 100644
--- a/httpbin/handlers_test.go
+++ b/httpbin/handlers_test.go
@@ -20,10 +20,12 @@ import (
 )
 
 const maxMemory int64 = 1024 * 1024
+const maxResponseSize = 1024
 const maxResponseTime time.Duration = 1 * time.Second
 
 var app = NewHTTPBin(&Options{
 	MaxMemory:       maxMemory,
+	MaxResponseSize: maxResponseSize,
 	MaxResponseTime: maxResponseTime,
 })
 
@@ -1291,3 +1293,104 @@ func TestDelay(t *testing.T) {
 		})
 	}
 }
+
+func TestDrip(t *testing.T) {
+	var okTests = []struct {
+		params   *url.Values
+		duration time.Duration
+		numbytes int
+		code     int
+	}{
+		// there are useful defaults for all values
+		{&url.Values{}, 0, 10, http.StatusOK},
+
+		// go-style durations are accepted
+		{&url.Values{"duration": {"5ms"}}, 5 * time.Millisecond, 10, http.StatusOK},
+		{&url.Values{"duration": {"0h"}}, 0, 10, http.StatusOK},
+		{&url.Values{"delay": {"5ms"}}, 5 * time.Millisecond, 10, http.StatusOK},
+		{&url.Values{"delay": {"0h"}}, 0, 10, http.StatusOK},
+
+		// or floating point seconds
+		{&url.Values{"duration": {"0.25"}}, 250 * time.Millisecond, 10, http.StatusOK},
+		{&url.Values{"duration": {"0"}}, 0, 10, http.StatusOK},
+		{&url.Values{"duration": {"1"}}, 1 * time.Second, 10, http.StatusOK},
+		{&url.Values{"delay": {"0.25"}}, 250 * time.Millisecond, 10, http.StatusOK},
+		{&url.Values{"delay": {"0"}}, 0, 10, http.StatusOK},
+
+		{&url.Values{"numbytes": {"1"}}, 0, 1, http.StatusOK},
+		{&url.Values{"numbytes": {"101"}}, 0, 101, http.StatusOK},
+		{&url.Values{"numbytes": {fmt.Sprintf("%d", maxResponseSize)}}, 0, maxResponseSize, http.StatusOK},
+
+		{&url.Values{"code": {"100"}}, 0, 10, 100},
+		{&url.Values{"code": {"404"}}, 0, 10, 404},
+		{&url.Values{"code": {"599"}}, 0, 10, 599},
+		{&url.Values{"code": {"567"}}, 0, 10, 567},
+
+		{&url.Values{"duration": {"750ms"}, "delay": {"250ms"}}, 1 * time.Second, 10, http.StatusOK},
+		{&url.Values{"duration": {"250ms"}, "delay": {"0.25s"}}, 500 * time.Millisecond, 10, http.StatusOK},
+	}
+	for _, test := range okTests {
+		t.Run(fmt.Sprintf("ok/%s", test.params.Encode()), func(t *testing.T) {
+			url := "/drip?" + test.params.Encode()
+
+			start := time.Now()
+
+			r, _ := http.NewRequest("GET", url, nil)
+			w := httptest.NewRecorder()
+			handler.ServeHTTP(w, r)
+
+			elapsed := time.Now().Sub(start)
+
+			assertHeader(t, w, "Content-Type", "application/octet-stream")
+			assertStatusCode(t, w, test.code)
+			if len(w.Body.Bytes()) != test.numbytes {
+				t.Fatalf("expected %d bytes, got %d", test.numbytes, len(w.Body.Bytes()))
+			}
+
+			if elapsed < test.duration {
+				t.Fatalf("expected minimum duration of %s, request took %s", test.duration, elapsed)
+			}
+		})
+	}
+
+	var badTests = []struct {
+		params *url.Values
+		code   int
+	}{
+		{&url.Values{"duration": {"1m"}}, http.StatusBadRequest},
+		{&url.Values{"duration": {"-1ms"}}, http.StatusBadRequest},
+		{&url.Values{"duration": {"1001"}}, http.StatusBadRequest},
+		{&url.Values{"duration": {"-1"}}, http.StatusBadRequest},
+		{&url.Values{"duration": {"foo"}}, http.StatusBadRequest},
+
+		{&url.Values{"delay": {"1m"}}, http.StatusBadRequest},
+		{&url.Values{"delay": {"-1ms"}}, http.StatusBadRequest},
+		{&url.Values{"delay": {"1001"}}, http.StatusBadRequest},
+		{&url.Values{"delay": {"-1"}}, http.StatusBadRequest},
+		{&url.Values{"delay": {"foo"}}, http.StatusBadRequest},
+
+		{&url.Values{"numbytes": {"foo"}}, http.StatusBadRequest},
+		{&url.Values{"numbytes": {"0"}}, http.StatusBadRequest},
+		{&url.Values{"numbytes": {"-1"}}, http.StatusBadRequest},
+		{&url.Values{"numbytes": {"0xff"}}, http.StatusBadRequest},
+		{&url.Values{"numbytes": {fmt.Sprintf("%d", maxResponseSize+1)}}, http.StatusBadRequest},
+
+		{&url.Values{"code": {"foo"}}, http.StatusBadRequest},
+		{&url.Values{"code": {"-1"}}, http.StatusBadRequest},
+		{&url.Values{"code": {"25"}}, http.StatusBadRequest},
+		{&url.Values{"code": {"600"}}, http.StatusBadRequest},
+
+		// request would take too long
+		{&url.Values{"duration": {"750ms"}, "delay": {"500ms"}}, http.StatusBadRequest},
+	}
+	for _, test := range badTests {
+		t.Run(fmt.Sprintf("bad/%s", test.params.Encode()), func(t *testing.T) {
+			url := "/drip?" + test.params.Encode()
+
+			r, _ := http.NewRequest("GET", 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 f5e79a5..27ce179 100644
--- a/httpbin/helpers.go
+++ b/httpbin/helpers.go
@@ -106,7 +106,8 @@ func parseBody(w http.ResponseWriter, r *http.Request, resp *bodyResponse, maxMe
 }
 
 // parseDuration takes a user's input as a string and attempts to convert it
-// into a time.Duration
+// into a time.Duration. If not given as a go-style duration string, the input
+// is assumed to be seconds as a float.
 func parseDuration(input string) (time.Duration, error) {
 	d, err := time.ParseDuration(input)
 	if err != nil {
diff --git a/httpbin/httpbin.go b/httpbin/httpbin.go
index 04cad81..31d06a5 100644
--- a/httpbin/httpbin.go
+++ b/httpbin/httpbin.go
@@ -73,6 +73,7 @@ type streamResponse struct {
 // Options are used to configure HTTPBin
 type Options struct {
 	MaxMemory       int64
+	MaxResponseSize int64
 	MaxResponseTime time.Duration
 }
 
@@ -118,6 +119,7 @@ func (h *HTTPBin) Handler() http.Handler {
 
 	mux.HandleFunc("/stream/", h.Stream)
 	mux.HandleFunc("/delay/", h.Delay)
+	mux.HandleFunc("/drip", h.Drip)
 
 	// Make sure our ServeMux doesn't "helpfully" redirect these invalid
 	// endpoints by adding a trailing slash. See the ServeMux docs for more
diff --git a/main.go b/main.go
index 47c9d90..83512f7 100644
--- a/main.go
+++ b/main.go
@@ -11,6 +11,7 @@ import (
 func main() {
 	h := httpbin.NewHTTPBin(&httpbin.Options{
 		MaxMemory:       1024 * 1024 * 5,
+		MaxResponseSize: 1024 * 1024,
 		MaxResponseTime: 10 * time.Second,
 	})
 	log.Printf("listening on 9999")
-- 
GitLab