From b1ec63a2166c5c910fae5262164e47e395e27cea Mon Sep 17 00:00:00 2001
From: RomeoApps <294788459+RomeoApps@users.noreply.github.com>
Date: Sun, 21 Jun 2026 14:33:19 +0800
Subject: [PATCH 1/4] Add compressed body echo endpoint
---
httpbin/handlers.go | 41 +++++++++++++++
httpbin/handlers_test.go | 94 ++++++++++++++++++++++++++++++++++
httpbin/httpbin.go | 1 +
httpbin/static/index.html.tmpl | 1 +
4 files changed, 137 insertions(+)
diff --git a/httpbin/handlers.go b/httpbin/handlers.go
index 9673d9e..4ac143b 100644
--- a/httpbin/handlers.go
+++ b/httpbin/handlers.go
@@ -122,6 +122,47 @@ func (h *HTTPBin) RequestWithBodyDiscard(w http.ResponseWriter, r *http.Request)
writeJSON(http.StatusOK, w, resp)
}
+// Echo returns the request body as plain text. If the incoming request uses
+// gzip or deflate Content-Encoding, the body is decompressed before echoing.
+func (h *HTTPBin) Echo(w http.ResponseWriter, r *http.Request) {
+ defer r.Body.Close()
+
+ var reader io.Reader = r.Body
+ encoding := strings.ToLower(r.Header.Get("Content-Encoding"))
+
+ switch {
+ case strings.Contains(encoding, "gzip"):
+ gzr, err := gzip.NewReader(r.Body)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, fmt.Errorf("error decoding gzip request body: %w", err))
+ return
+ }
+ defer gzr.Close()
+ reader = gzr
+
+ case strings.Contains(encoding, "deflate"):
+ zr, err := zlib.NewReader(r.Body)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, fmt.Errorf("error decoding deflate request body: %w", err))
+ return
+ }
+ defer zr.Close()
+ reader = zr
+ }
+
+ body, err := io.ReadAll(io.LimitReader(reader, h.MaxBodySize+1))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, fmt.Errorf("error reading request body: %w", err))
+ return
+ }
+ if int64(len(body)) > h.MaxBodySize {
+ writeError(w, http.StatusBadRequest, fmt.Errorf("request body exceeds maximum size of %d bytes", h.MaxBodySize))
+ return
+ }
+
+ writeResponse(w, http.StatusOK, textContentType, body)
+}
+
// Gzip returns a gzipped response
func (h *HTTPBin) Gzip(w http.ResponseWriter, r *http.Request) {
var (
diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go
index 7ab4603..fb18daf 100644
--- a/httpbin/handlers_test.go
+++ b/httpbin/handlers_test.go
@@ -593,6 +593,100 @@ func TestAnything(t *testing.T) {
})
}
+func TestEcho(t *testing.T) {
+ t.Parallel()
+
+ app := setupTestApp(t)
+ overLimitBody := strings.Repeat("x", 65)
+
+ t.Run("plain body", func(t *testing.T) {
+ t.Parallel()
+
+ req := newTestRequest(t, "POST", app.URL("/echo"), strings.NewReader("I am deflater mouse"))
+ resp := mustDoRequest(t, app, req)
+
+ assert.StatusCode(t, resp, http.StatusOK)
+ assert.ContentType(t, resp, textContentType)
+ assert.BodyEquals(t, resp, "I am deflater mouse")
+ })
+
+ t.Run("gzip content encoding", func(t *testing.T) {
+ t.Parallel()
+
+ var buf bytes.Buffer
+ gzw := gzip.NewWriter(&buf)
+ _, err := gzw.Write([]byte("I am gzip mouse"))
+ assert.NilError(t, err)
+ assert.NilError(t, gzw.Close())
+
+ req := newTestRequest(t, "POST", app.URL("/echo"), &buf)
+ req.Header.Set("Content-Encoding", "gzip")
+ resp := mustDoRequest(t, app, req)
+
+ assert.StatusCode(t, resp, http.StatusOK)
+ assert.ContentType(t, resp, textContentType)
+ assert.BodyEquals(t, resp, "I am gzip mouse")
+ })
+
+ t.Run("gzip content encoding too big after decompression", func(t *testing.T) {
+ t.Parallel()
+
+ app := setupTestApp(t, WithMaxBodySize(64))
+ var buf bytes.Buffer
+ gzw := gzip.NewWriter(&buf)
+ _, err := gzw.Write([]byte(overLimitBody))
+ assert.NilError(t, err)
+ assert.NilError(t, gzw.Close())
+ if buf.Len() >= 64 {
+ t.Fatalf("compressed test body must stay under MaxBodySize to exercise the decompressed limit")
+ }
+
+ req := newTestRequest(t, "POST", app.URL("/echo"), &buf)
+ req.Header.Set("Content-Encoding", "gzip")
+ resp := mustDoRequest(t, app, req)
+
+ assert.StatusCode(t, resp, http.StatusBadRequest)
+ })
+
+ t.Run("deflate content encoding", func(t *testing.T) {
+ t.Parallel()
+
+ var buf bytes.Buffer
+ zw := zlib.NewWriter(&buf)
+ _, err := zw.Write([]byte("I am deflate mouse"))
+ assert.NilError(t, err)
+ assert.NilError(t, zw.Close())
+
+ req := newTestRequest(t, "POST", app.URL("/echo"), &buf)
+ req.Header.Set("Content-Encoding", "deflate")
+ resp := mustDoRequest(t, app, req)
+
+ assert.StatusCode(t, resp, http.StatusOK)
+ assert.ContentType(t, resp, textContentType)
+ assert.BodyEquals(t, resp, "I am deflate mouse")
+ })
+
+ t.Run("deflate content encoding too big after decompression", func(t *testing.T) {
+ t.Parallel()
+
+ app := setupTestApp(t, WithMaxBodySize(64))
+ var buf bytes.Buffer
+ zw := zlib.NewWriter(&buf)
+ _, err := zw.Write([]byte(overLimitBody))
+ assert.NilError(t, err)
+ assert.NilError(t, zw.Close())
+ if buf.Len() >= 64 {
+ t.Fatalf("compressed test body must stay under MaxBodySize to exercise the decompressed limit")
+ }
+
+ req := newTestRequest(t, "POST", app.URL("/echo"), &buf)
+ req.Header.Set("Content-Encoding", "deflate")
+ resp := mustDoRequest(t, app, req)
+
+ assert.StatusCode(t, resp, http.StatusBadRequest)
+ })
+}
+
func testRequestWithBody(t *testing.T, app *appTestInfo, verb, path string) {
t.Run("BinaryBody", func(t *testing.T) {
t.Parallel()
diff --git a/httpbin/httpbin.go b/httpbin/httpbin.go
index c16675b..e54e3fc 100644
--- a/httpbin/httpbin.go
+++ b/httpbin/httpbin.go
@@ -194,6 +194,7 @@ func (h *HTTPBin) Handler() http.Handler {
mux.HandleFunc("/digest-auth/{qop}/{user}/{password}/{algorithm}", h.DigestAuth)
mux.HandleFunc("/drip", h.Drip)
mux.HandleFunc("/dump/request", h.DumpRequest)
+ mux.HandleFunc("/echo", h.Echo)
mux.HandleFunc("/env", h.Env)
mux.HandleFunc("/etag/{etag}", h.ETag)
mux.HandleFunc("/gzip", h.Gzip)
diff --git a/httpbin/static/index.html.tmpl b/httpbin/static/index.html.tmpl
index 4e8a5fc..ae2a1bb 100644
--- a/httpbin/static/index.html.tmpl
+++ b/httpbin/static/index.html.tmpl
@@ -81,6 +81,7 @@
{{.Prefix}}/digest-auth/:qop/:user/:password/:algorithm Challenges HTTP Digest Auth using specified algorithm (MD5 or SHA-256)
{{.Prefix}}/drip?numbytes=n&duration=s&delay=s&code=code Drips data over the given duration after an optional initial delay, simulating a slow HTTP server.
{{.Prefix}}/dump/request Returns the given request in its HTTP/1.x wire approximate representation.
+{{.Prefix}}/echo Echoes the request body as plain text, decompressing gzip or deflate request bodies.
{{.Prefix}}/encoding/utf8 Returns page containing UTF-8 data.
{{.Prefix}}/env Returns all environment variables named with HTTPBIN_ENV_ prefix.
{{.Prefix}}/etag/:etag Assumes the resource has the given etag and responds to If-None-Match header with a 200 or 304 and If-Match with a 200 or 412 as appropriate.
From 88c38b63e60d2542b6f3d76bebfd8683adc7e639 Mon Sep 17 00:00:00 2001
From: RomeoApps <294788459+RomeoApps@users.noreply.github.com>
Date: Mon, 22 Jun 2026 12:07:50 +0800
Subject: [PATCH 2/4] Address echo endpoint review feedback
---
httpbin/handlers.go | 12 ++++++---
httpbin/handlers_test.go | 55 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 64 insertions(+), 3 deletions(-)
diff --git a/httpbin/handlers.go b/httpbin/handlers.go
index 4ac143b..aba81d0 100644
--- a/httpbin/handlers.go
+++ b/httpbin/handlers.go
@@ -122,13 +122,17 @@ func (h *HTTPBin) RequestWithBodyDiscard(w http.ResponseWriter, r *http.Request)
writeJSON(http.StatusOK, w, resp)
}
-// Echo returns the request body as plain text. If the incoming request uses
-// gzip or deflate Content-Encoding, the body is decompressed before echoing.
+// Echo returns the request body. If the incoming request uses gzip or deflate
+// Content-Encoding, the body is decompressed before echoing.
func (h *HTTPBin) Echo(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var reader io.Reader = r.Body
encoding := strings.ToLower(r.Header.Get("Content-Encoding"))
+ contentType := r.Header.Get("Content-Type")
+ if contentType == "" {
+ contentType = textContentType
+ }
switch {
case strings.Contains(encoding, "gzip"):
@@ -150,6 +154,8 @@ func (h *HTTPBin) Echo(w http.ResponseWriter, r *http.Request) {
reader = zr
}
+ // Read one byte past the limit so over-limit decoded bodies can be
+ // rejected instead of silently truncated.
body, err := io.ReadAll(io.LimitReader(reader, h.MaxBodySize+1))
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("error reading request body: %w", err))
@@ -160,7 +166,7 @@ func (h *HTTPBin) Echo(w http.ResponseWriter, r *http.Request) {
return
}
- writeResponse(w, http.StatusOK, textContentType, body)
+ writeResponse(w, http.StatusOK, contentType, body)
}
// Gzip returns a gzipped response
diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go
index fb18daf..69243d0 100644
--- a/httpbin/handlers_test.go
+++ b/httpbin/handlers_test.go
@@ -610,6 +610,18 @@ func TestEcho(t *testing.T) {
assert.BodyEquals(t, resp, "I am deflater mouse")
})
+ t.Run("preserves request content type", func(t *testing.T) {
+ t.Parallel()
+
+ req := newTestRequest(t, "POST", app.URL("/echo"), strings.NewReader(`{"ok":true}`))
+ req.Header.Set("Content-Type", "application/json")
+ resp := mustDoRequest(t, app, req)
+
+ assert.StatusCode(t, resp, http.StatusOK)
+ assert.ContentType(t, resp, "application/json")
+ assert.BodyEquals(t, resp, `{"ok":true}`)
+ })
+
t.Run("gzip content encoding", func(t *testing.T) {
t.Parallel()
@@ -628,6 +640,39 @@ func TestEcho(t *testing.T) {
assert.BodyEquals(t, resp, "I am gzip mouse")
})
+ t.Run("invalid gzip content encoding", func(t *testing.T) {
+ t.Parallel()
+
+ req := newTestRequest(t, "POST", app.URL("/echo"), strings.NewReader("not gzip"))
+ req.Header.Set("Content-Encoding", "gzip")
+ resp := mustDoRequest(t, app, req)
+
+ assert.StatusCode(t, resp, http.StatusBadRequest)
+ })
+
+ t.Run("gzip content encoding exactly at decoded limit", func(t *testing.T) {
+ t.Parallel()
+
+ app := setupTestApp(t, WithMaxBodySize(64))
+ decodedBody := strings.Repeat("x", 64)
+
+ var buf bytes.Buffer
+ gzw := gzip.NewWriter(&buf)
+ _, err := gzw.Write([]byte(decodedBody))
+ assert.NilError(t, err)
+ assert.NilError(t, gzw.Close())
+ if buf.Len() >= 64 {
+ t.Fatalf("compressed test body must stay under MaxBodySize to exercise the decoded limit")
+ }
+
+ req := newTestRequest(t, "POST", app.URL("/echo"), &buf)
+ req.Header.Set("Content-Encoding", "gzip")
+ resp := mustDoRequest(t, app, req)
+
+ assert.StatusCode(t, resp, http.StatusOK)
+ assert.BodyEquals(t, resp, decodedBody)
+ })
+
t.Run("gzip content encoding too big after decompression", func(t *testing.T) {
t.Parallel()
@@ -666,6 +711,16 @@ func TestEcho(t *testing.T) {
assert.BodyEquals(t, resp, "I am deflate mouse")
})
+ t.Run("invalid deflate content encoding", func(t *testing.T) {
+ t.Parallel()
+
+ req := newTestRequest(t, "POST", app.URL("/echo"), strings.NewReader("not deflate"))
+ req.Header.Set("Content-Encoding", "deflate")
+ resp := mustDoRequest(t, app, req)
+
+ assert.StatusCode(t, resp, http.StatusBadRequest)
+ })
+
t.Run("deflate content encoding too big after decompression", func(t *testing.T) {
t.Parallel()
From 0ae32687fed6de4eb9b8b667715bac17f2a8bc6b Mon Sep 17 00:00:00 2001
From: RomeoApps <294788459+RomeoApps@users.noreply.github.com>
Date: Mon, 22 Jun 2026 20:58:29 +0800
Subject: [PATCH 3/4] Add raw echo mode
---
httpbin/handlers.go | 37 ++++++++++++++++++----------------
httpbin/handlers_test.go | 32 +++++++++++++++++++++++++++++
httpbin/static/index.html.tmpl | 2 +-
3 files changed, 53 insertions(+), 18 deletions(-)
diff --git a/httpbin/handlers.go b/httpbin/handlers.go
index aba81d0..a4285d2 100644
--- a/httpbin/handlers.go
+++ b/httpbin/handlers.go
@@ -123,7 +123,8 @@ func (h *HTTPBin) RequestWithBodyDiscard(w http.ResponseWriter, r *http.Request)
}
// Echo returns the request body. If the incoming request uses gzip or deflate
-// Content-Encoding, the body is decompressed before echoing.
+// Content-Encoding, the body is decompressed before echoing unless the raw
+// query parameter is set.
func (h *HTTPBin) Echo(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
@@ -134,24 +135,26 @@ func (h *HTTPBin) Echo(w http.ResponseWriter, r *http.Request) {
contentType = textContentType
}
- switch {
- case strings.Contains(encoding, "gzip"):
- gzr, err := gzip.NewReader(r.Body)
- if err != nil {
- writeError(w, http.StatusBadRequest, fmt.Errorf("error decoding gzip request body: %w", err))
- return
- }
- defer gzr.Close()
- reader = gzr
+ if !r.URL.Query().Has("raw") {
+ switch {
+ case strings.Contains(encoding, "gzip"):
+ gzr, err := gzip.NewReader(r.Body)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, fmt.Errorf("error decoding gzip request body: %w", err))
+ return
+ }
+ defer gzr.Close()
+ reader = gzr
- case strings.Contains(encoding, "deflate"):
- zr, err := zlib.NewReader(r.Body)
- if err != nil {
- writeError(w, http.StatusBadRequest, fmt.Errorf("error decoding deflate request body: %w", err))
- return
+ case strings.Contains(encoding, "deflate"):
+ zr, err := zlib.NewReader(r.Body)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, fmt.Errorf("error decoding deflate request body: %w", err))
+ return
+ }
+ defer zr.Close()
+ reader = zr
}
- defer zr.Close()
- reader = zr
}
// Read one byte past the limit so over-limit decoded bodies can be
diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go
index 69243d0..2b26737 100644
--- a/httpbin/handlers_test.go
+++ b/httpbin/handlers_test.go
@@ -640,6 +640,27 @@ func TestEcho(t *testing.T) {
assert.BodyEquals(t, resp, "I am gzip mouse")
})
+ t.Run("gzip content encoding raw", func(t *testing.T) {
+ t.Parallel()
+
+ var buf bytes.Buffer
+ gzw := gzip.NewWriter(&buf)
+ _, err := gzw.Write([]byte("I am raw gzip mouse"))
+ assert.NilError(t, err)
+ assert.NilError(t, gzw.Close())
+ compressedBody := append([]byte(nil), buf.Bytes()...)
+
+ req := newTestRequest(t, "POST", app.URL("/echo?raw"), bytes.NewReader(compressedBody))
+ req.Header.Set("Content-Encoding", "gzip")
+ resp := mustDoRequest(t, app, req)
+
+ assert.StatusCode(t, resp, http.StatusOK)
+ assert.ContentType(t, resp, textContentType)
+ body, err := io.ReadAll(resp.Body)
+ assert.NilError(t, err)
+ assert.DeepEqual(t, body, compressedBody, "raw echo body")
+ })
+
t.Run("invalid gzip content encoding", func(t *testing.T) {
t.Parallel()
@@ -650,6 +671,17 @@ func TestEcho(t *testing.T) {
assert.StatusCode(t, resp, http.StatusBadRequest)
})
+ t.Run("invalid gzip content encoding raw", func(t *testing.T) {
+ t.Parallel()
+
+ req := newTestRequest(t, "POST", app.URL("/echo?raw"), strings.NewReader("not gzip"))
+ req.Header.Set("Content-Encoding", "gzip")
+ resp := mustDoRequest(t, app, req)
+
+ assert.StatusCode(t, resp, http.StatusOK)
+ assert.BodyEquals(t, resp, "not gzip")
+ })
+
t.Run("gzip content encoding exactly at decoded limit", func(t *testing.T) {
t.Parallel()
diff --git a/httpbin/static/index.html.tmpl b/httpbin/static/index.html.tmpl
index ae2a1bb..71de4c0 100644
--- a/httpbin/static/index.html.tmpl
+++ b/httpbin/static/index.html.tmpl
@@ -81,7 +81,7 @@
{{.Prefix}}/digest-auth/:qop/:user/:password/:algorithm Challenges HTTP Digest Auth using specified algorithm (MD5 or SHA-256)
{{.Prefix}}/drip?numbytes=n&duration=s&delay=s&code=code Drips data over the given duration after an optional initial delay, simulating a slow HTTP server.
{{.Prefix}}/dump/request Returns the given request in its HTTP/1.x wire approximate representation.
-{{.Prefix}}/echo Echoes the request body as plain text, decompressing gzip or deflate request bodies.
+{{.Prefix}}/echo Echoes the request body as plain text, decompressing gzip or deflate request bodies unless ?raw is set.
{{.Prefix}}/encoding/utf8 Returns page containing UTF-8 data.
{{.Prefix}}/env Returns all environment variables named with HTTPBIN_ENV_ prefix.
{{.Prefix}}/etag/:etag Assumes the resource has the given etag and responds to If-None-Match header with a 200 or 304 and If-Match with a 200 or 412 as appropriate.
From cc82227e24a12ccb6a4f592b67c0e8adb92ef3d9 Mon Sep 17 00:00:00 2001
From: RomeoApps <294788459+RomeoApps@users.noreply.github.com>
Date: Tue, 23 Jun 2026 07:08:50 +0800
Subject: [PATCH 4/4] Cover echo error path
---
httpbin/handlers_test.go | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go
index 2b26737..b8c56b2 100644
--- a/httpbin/handlers_test.go
+++ b/httpbin/handlers_test.go
@@ -56,6 +56,16 @@ type targetConfig struct {
MaxJSONLCount int64
}
+type errorReadCloser struct{}
+
+func (errorReadCloser) Read(_ []byte) (int, error) {
+ return 0, io.ErrUnexpectedEOF
+}
+
+func (errorReadCloser) Close() error {
+ return nil
+}
+
// configFromApp builds a [targetConfig] from an in-process [HTTPBin].
func configFromApp(app *HTTPBin) targetConfig {
return targetConfig{
@@ -148,6 +158,8 @@ func TestIndex(t *testing.T) {
body := must.ReadAll(t, resp.Body)
assert.Contains(t, body, "go-httpbin", "body")
assert.Contains(t, body, prefix+"/get", "body")
+ assert.Contains(t, body, prefix+"/echo", "body")
+ assert.Contains(t, body, "?raw", "body")
})
t.Run("not found"+prefix, func(t *testing.T) {
@@ -772,6 +784,21 @@ func TestEcho(t *testing.T) {
assert.StatusCode(t, resp, http.StatusBadRequest)
})
+
+ t.Run("body read error", func(t *testing.T) {
+ t.Parallel()
+
+ app := createApp()
+ req := httptest.NewRequest("POST", "/echo", nil)
+ req.Body = errorReadCloser{}
+ w := httptest.NewRecorder()
+
+ app.Echo(w, req)
+ resp := w.Result()
+
+ assert.StatusCode(t, resp, http.StatusBadRequest)
+ assert.BodyContains(t, resp, "error reading request body")
+ })
}
func testRequestWithBody(t *testing.T, app *appTestInfo, verb, path string) {