diff --git a/httpbin/handlers.go b/httpbin/handlers.go index e8a3c07944d53a95c24e4340e105cd1eec022de6..61b7e7289a78788e2e47f1ba95be33e05c0beec9 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 07036a359dce0006b16afef5486f08c278a3137e..ba86ffca232e29822f32d89577a1f45728be59f5 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 f5e79a57d3fcdcfa6ff60e278aff0baab7ace655..27ce179d809da93a2e9034f386669f15c615c21a 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 04cad814942e3e6578075a342377ed3a4b235038..31d06a59260d21c83d411b701901a8a7baf68166 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 47c9d90ed16fe316adbbf896e02465389eaf6a5d..83512f72facb608bf16ff4b3c7f3dba7cfb70d33 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")