From 2928ca0805900127bf02ccbdd8d9d8e02498b2d7 Mon Sep 17 00:00:00 2001 From: Will McCutchen Date: Mon, 16 Sep 2024 18:15:37 -0400 Subject: [PATCH] feat: add /trailers endpoint --- httpbin/handlers.go | 46 ++++++++++++++++++++++++++++- httpbin/handlers_test.go | 53 ++++++++++++++++++++++++++++++++++ httpbin/httpbin.go | 1 + httpbin/static/index.html.tmpl | 1 + 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/httpbin/handlers.go b/httpbin/handlers.go index 7e060dd..3a0e15b 100644 --- a/httpbin/handlers.go +++ b/httpbin/handlers.go @@ -70,7 +70,8 @@ func (h *HTTPBin) Anything(w http.ResponseWriter, r *http.Request) { h.RequestWithBody(w, r) } -// RequestWithBody handles POST, PUT, and PATCH requests +// RequestWithBody handles POST, PUT, and PATCH requests by responding with a +// JSON representation of the incoming request. func (h *HTTPBin) RequestWithBody(w http.ResponseWriter, r *http.Request) { resp := &bodyResponse{ Args: r.URL.Query(), @@ -548,6 +549,49 @@ func (h *HTTPBin) Stream(w http.ResponseWriter, r *http.Request) { } } +// set of keys that may not be specified in trailers, per +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Trailer#directives +var forbiddenTrailers = map[string]struct{}{ + http.CanonicalHeaderKey("Authorization"): {}, + http.CanonicalHeaderKey("Cache-Control"): {}, + http.CanonicalHeaderKey("Content-Encoding"): {}, + http.CanonicalHeaderKey("Content-Length"): {}, + http.CanonicalHeaderKey("Content-Range"): {}, + http.CanonicalHeaderKey("Content-Type"): {}, + http.CanonicalHeaderKey("Host"): {}, + http.CanonicalHeaderKey("Max-Forwards"): {}, + http.CanonicalHeaderKey("Set-Cookie"): {}, + http.CanonicalHeaderKey("TE"): {}, + http.CanonicalHeaderKey("Trailer"): {}, + http.CanonicalHeaderKey("Transfer-Encoding"): {}, +} + +// Trailers adds the header keys and values specified in the request's query +// parameters as HTTP trailers in the response. +// +// Trailers are returned in canonical form. Any forbidden trailer will result +// in an error. +func (h *HTTPBin) Trailers(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + // ensure all requested trailers are allowed + for k := range q { + if _, found := forbiddenTrailers[http.CanonicalHeaderKey(k)]; found { + writeError(w, http.StatusBadRequest, fmt.Errorf("forbidden trailer: %s", k)) + return + } + } + for k := range q { + w.Header().Add("Trailer", k) + } + h.RequestWithBody(w, r) + w.(http.Flusher).Flush() // force chunked transfer encoding even when no trailers are given + for k, vs := range q { + for _, v := range vs { + w.Header().Set(k, v) + } + } +} + // Delay waits for a given amount of time before responding, where the time may // be specified as a golang-style duration or seconds in floating point. func (h *HTTPBin) Delay(w http.ResponseWriter, r *http.Request) { diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go index 8774447..2532b82 100644 --- a/httpbin/handlers_test.go +++ b/httpbin/handlers_test.go @@ -1844,6 +1844,59 @@ func TestStream(t *testing.T) { } } +func TestTrailers(t *testing.T) { + t.Parallel() + + testCases := []struct { + url string + wantStatus int + wantTrailers http.Header + }{ + { + "/trailers", + http.StatusOK, + nil, + }, + { + "/trailers?test-trailer-1=v1&Test-Trailer-2=v2", + http.StatusOK, + // note that response headers are canonicalized + http.Header{"Test-Trailer-1": {"v1"}, "Test-Trailer-2": {"v2"}}, + }, + { + "/trailers?test-trailer-1&Authorization=Bearer", + http.StatusBadRequest, + nil, + }, + } + for _, tc := range testCases { + tc := tc + t.Run(tc.url, func(t *testing.T) { + t.Parallel() + + req := newTestRequest(t, "GET", tc.url) + resp := must.DoReq(t, client, req) + + assert.StatusCode(t, resp, tc.wantStatus) + if tc.wantStatus != http.StatusOK { + return + } + + // trailers only sent w/ chunked transfer encoding + assert.DeepEqual(t, resp.TransferEncoding, []string{"chunked"}, "expected Transfer-Encoding: chunked") + + // must read entire body to get trailers + body := must.ReadAll(t, resp.Body) + + // don't really care about the contents, as long as body can be + // unmarshaled into the correct type + must.Unmarshal[bodyResponse](t, strings.NewReader(body)) + + assert.DeepEqual(t, resp.Trailer, tc.wantTrailers, "trailers mismatch") + }) + } +} + func TestDelay(t *testing.T) { t.Parallel() diff --git a/httpbin/httpbin.go b/httpbin/httpbin.go index 6d04b23..82ae6e6 100644 --- a/httpbin/httpbin.go +++ b/httpbin/httpbin.go @@ -181,6 +181,7 @@ func (h *HTTPBin) Handler() http.Handler { mux.HandleFunc("/status/{code}", h.Status) mux.HandleFunc("/stream-bytes/{numBytes}", h.StreamBytes) mux.HandleFunc("/stream/{numLines}", h.Stream) + mux.HandleFunc("/trailers", h.Trailers) mux.HandleFunc("/unstable", h.Unstable) mux.HandleFunc("/user-agent", h.UserAgent) mux.HandleFunc("/uuid", h.UUID) diff --git a/httpbin/static/index.html.tmpl b/httpbin/static/index.html.tmpl index 20523b9..feadfd3 100644 --- a/httpbin/static/index.html.tmpl +++ b/httpbin/static/index.html.tmpl @@ -113,6 +113,7 @@
  • {{.Prefix}}/status/:code Returns given HTTP Status code.
  • {{.Prefix}}/stream-bytes/:n Streams n random bytes of binary data, accepts optional seed and chunk_size integer parameters.
  • {{.Prefix}}/stream/:n Streams min(n, 100) lines.
  • +
  • {{.Prefix}}/trailers?key=val Returns JSON response with query params added as HTTP Trailers.
  • {{.Prefix}}/unstable Fails half the time, accepts optional failure_rate float and seed integer parameters.
  • {{.Prefix}}/user-agent Returns user-agent.
  • {{.Prefix}}/uuid Generates a UUIDv4 value.