From c7e554adab26e90e0479f4603d12e3b20f389a80 Mon Sep 17 00:00:00 2001 From: Henrique Vicente Date: Sun, 3 May 2026 14:46:13 +0200 Subject: [PATCH 01/10] chore(deps): bump GitHub CI dependencies --- .github/workflows/codeql.yml | 6 +++--- .github/workflows/go.yml | 4 ++-- .github/workflows/lint.yml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 74ea26b..f99b687 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,12 +21,12 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: go - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 05f74b4..10652a8 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -19,12 +19,12 @@ jobs: steps: - name: Set up Go ${{ matrix.go }} - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Test # TODO(henvic): Skip generating code coverage when not sending it to Coveralls to speed up testing. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 77ba7df..809100e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,12 +16,12 @@ jobs: steps: - name: Set up Go 1.x - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: "1.23.x" - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Verify dependencies run: | From bbd49ec9d543a0c09a2f78ad7be049aa47d65d18 Mon Sep 17 00:00:00 2001 From: Henrique Vicente Date: Sun, 3 May 2026 15:02:29 +0200 Subject: [PATCH 02/10] fix: isBinary was checking wrong portion of body body[512:] examined bytes *after* position 512 instead of the first 512 bytes. Binary detection (BOM check, control character scan, and http.DetectContentType) operated on the tail of the body rather than the head, which is what the MIME sniffing spec and DetectContentType expect. A body of length 513+ with a leading BOM was misclassified, since the BOM check requires len(body) >= 3 against the start of the slice. --- binary_test.go | 15 +++++++++++++++ printer.go | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/binary_test.go b/binary_test.go index 08b480d..16483e0 100644 --- a/binary_test.go +++ b/binary_test.go @@ -50,6 +50,21 @@ func TestIsBinary(t *testing.T) { data: bytes.Repeat([]byte{1, 2, 3, 4, 5, 6, 7, 8}, 65), binary: true, }, + { + desc: "Binary header (exactly 512 bytes) with text trailer", + data: append(bytes.Repeat([]byte{1, 2, 3, 4, 5, 6, 7, 8}, 64), []byte("plain text trailer")...), + binary: true, + }, + { + desc: "Text over 512 bytes with binary trailer", + data: append(bytes.Repeat([]byte("plain text "), 50), []byte{1, 2, 3, 4, 5}...), + binary: false, + }, + { + desc: "Large text with leading UTF-8 BOM and binary trailer", // https://www.unicode.org/faq/utf_bom#BOM + data: append(append([]byte("\xEF\xBB\xBF"), bytes.Repeat([]byte("plain text "), 50)...), []byte{1, 2, 3, 4, 5}...), + binary: false, + }, { desc: "JPEG image", data: []byte("\xFF\xD8\xFF"), diff --git a/printer.go b/printer.go index b85b28f..af8ce1a 100644 --- a/printer.go +++ b/printer.go @@ -205,7 +205,7 @@ func (p *printer) printResponseBodyOut(resp *http.Response) { // See discussion at https://groups.google.com/forum/#!topic/golang-nuts/YeLL7L7SwWs func isBinary(body []byte) bool { if len(body) > 512 { - body = body[512:] + body = body[:512] } // If file contains UTF-8 OR UTF-16 BOM, consider it non-binary. // Reference: https://tools.ietf.org/html/draft-ietf-websec-mime-sniff-03#section-5 From 6fa35b60878d665effab7f4b81c36e49f61fcbb2 Mon Sep 17 00:00:00 2001 From: Henrique Vicente Date: Sun, 3 May 2026 15:21:03 +0200 Subject: [PATCH 03/10] fix: printServerResponse Content-Type check should use http.Response --- printer.go | 2 +- server_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/printer.go b/printer.go index af8ce1a..e3b5c4a 100644 --- a/printer.go +++ b/printer.go @@ -410,7 +410,7 @@ func (p *printer) printServerResponse(req *http.Request, rec *responseRecorder) if skip { return } - if mediatype := req.Header.Get("Content-Type"); mediatype != "" && isBinaryMediatype(mediatype) { + if mediatype := rec.Header().Get("Content-Type"); mediatype != "" && isBinaryMediatype(mediatype) { p.println("* body contains binary data") return } diff --git a/server_test.go b/server_test.go index b820cc9..d4b1475 100644 --- a/server_test.go +++ b/server_test.go @@ -790,6 +790,49 @@ func TestIncomingBinaryBodyNoMediatypeHeader(t *testing.T) { } } +func TestIncomingBinaryResponseTextRequest(t *testing.T) { + t.Parallel() + logger := &Logger{ + RequestHeader: true, + RequestBody: true, + ResponseHeader: true, + ResponseBody: true, + } + var buf bytes.Buffer + logger.SetOutput(&buf) + is := inspect(logger.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header()["Date"] = nil + // Respond with a binary Content-Type but a body that has no binary bytes, + // so only the Content-Type header check can catch it (not the byte-level heuristic). + w.Header().Set("Content-Type", "application/pdf") + fmt.Fprint(w, "not really a pdf") + })), 1) + + ts := httptest.NewServer(is) + defer ts.Close() + go func() { + client := newServerClient() + req, err := http.NewRequest(http.MethodPost, ts.URL, strings.NewReader(`{"query":"convert"}`)) + if err != nil { + t.Errorf("cannot create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + if _, err = client.Do(req); err != nil { + t.Errorf("cannot connect to the server: %v", err) + } + }() + is.Wait() + got := buf.String() + // The response body should be detected as binary based on the response Content-Type (application/pdf), + // not the request Content-Type (application/json). + if !strings.Contains(got, "* body contains binary data") { + t.Errorf("expected response body to be detected as binary based on response Content-Type, got:\n%s", got) + } + if !strings.Contains(got, `{"query":"convert"}`) { + t.Errorf("expected request body to be printed, but it was missing from:\n%s", got) + } +} + func TestIncomingLongRequest(t *testing.T) { t.Parallel() logger := &Logger{ From c27122d730545235cc34aadfacaf1b6b8b158fdc Mon Sep 17 00:00:00 2001 From: Henrique Vicente Date: Sun, 3 May 2026 16:00:48 +0200 Subject: [PATCH 04/10] refactor: use tls.VersionName and tls.CipherSuiteName from stdlib - Removes the locally maintained tls.go maps in favor of the standard library. - tls.CipherSuiteName available since Go 1.14. - tls.VersionName available since Go 1.21. --- printer.go | 10 ++-------- tls.go | 49 ------------------------------------------------- 2 files changed, 2 insertions(+), 57 deletions(-) delete mode 100644 tls.go diff --git a/printer.go b/printer.go index e3b5c4a..1a59fc0 100644 --- a/printer.go +++ b/printer.go @@ -309,14 +309,8 @@ func (p *printer) printTLSInfo(state *tls.ConnectionState, skipVerifyChains bool if state == nil { return } - protocol := tlsProtocolVersions[state.Version] - if protocol == "" { - protocol = fmt.Sprintf("%#v", state.Version) - } - cipher := tlsCiphers[state.CipherSuite] - if cipher == "" { - cipher = fmt.Sprintf("%#v", state.CipherSuite) - } + protocol := tls.VersionName(state.Version) + cipher := tls.CipherSuiteName(state.CipherSuite) p.printf("* TLS connection using %s / %s", p.format(color.FgBlue, protocol), p.format(color.FgBlue, cipher)) if !skipVerifyChains && state.VerifiedChains == nil { p.print(" (insecure=true)") diff --git a/tls.go b/tls.go deleted file mode 100644 index 70c09d1..0000000 --- a/tls.go +++ /dev/null @@ -1,49 +0,0 @@ -package httpretty - -// A list of cipher suite IDs that are, or have been, implemented by the -// crypto/tls package. -// See https://www.iana.org/assignments/tls-parameters/tls-parameters.xml -// See https://github.com/golang/go/blob/c2edcf4b1253fdebc13df8a25979904c3ef01c66/src/crypto/tls/cipher_suites.go -var tlsCiphers = map[uint16]string{ - // TLS 1.0 - 1.2 cipher suites. - 0x0005: "TLS_RSA_WITH_RC4_128_SHA", - 0x000a: "TLS_RSA_WITH_3DES_EDE_CBC_SHA", - 0x002f: "TLS_RSA_WITH_AES_128_CBC_SHA", - 0x0035: "TLS_RSA_WITH_AES_256_CBC_SHA", - 0x003c: "TLS_RSA_WITH_AES_128_CBC_SHA256", - 0x009c: "TLS_RSA_WITH_AES_128_GCM_SHA256", - 0x009d: "TLS_RSA_WITH_AES_256_GCM_SHA384", - 0xc007: "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", - 0xc009: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", - 0xc00a: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", - 0xc011: "TLS_ECDHE_RSA_WITH_RC4_128_SHA", - 0xc012: "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", - 0xc013: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", - 0xc014: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", - 0xc023: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", - 0xc027: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", - 0xc02f: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", - 0xc02b: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", - 0xc030: "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", - 0xc02c: "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", - 0xcca8: "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", - 0xcca9: "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", - - // TLS 1.3 cipher suites. - 0x1301: "TLS_AES_128_GCM_SHA256", - 0x1302: "TLS_AES_256_GCM_SHA384", - 0x1303: "TLS_CHACHA20_POLY1305_SHA256", - - // TLS_FALLBACK_SCSV isn't a standard cipher suite but an indicator - // that the client is doing version fallback. See RFC 7507. - 0x5600: "TLS_FALLBACK_SCSV", -} - -// List of TLS protocol versions supported by Go. -// See https://github.com/golang/go/blob/f4a8bf128364e852cff87cf404a5c16c457ef8f6/src/crypto/tls/common.go -var tlsProtocolVersions = map[uint16]string{ - 0x0301: "TLS 1.0", - 0x0302: "TLS 1.1", - 0x0303: "TLS 1.2", - 0x0304: "TLS 1.3", -} From 2ffd8d7f05ac0dace743ca40a9f00f38622fa596 Mon Sep 17 00:00:00 2001 From: Henrique Vicente Date: Sun, 3 May 2026 23:52:49 +0200 Subject: [PATCH 05/10] feat: print subjectAltName line in curl format on printCertificate --- client_test.go | 35 ++++++++++++++++++++++++++++ printer.go | 57 ++++++++++++++++++++++++++++++++++++++++++++-- testdata/log.txtar | 22 ++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/client_test.go b/client_test.go index 30ca788..fcee01f 100644 --- a/client_test.go +++ b/client_test.go @@ -1336,6 +1336,41 @@ func TestOutgoingTLS(t *testing.T) { testBody(t, resp.Body, []byte("Hello, world!")) } +func TestOutgoingTLSIPSAN(t *testing.T) { + t.Parallel() + ts := httptest.NewTLSServer(&helloHandler{}) + defer ts.Close() + + logger := &Logger{ + TLS: true, + RequestHeader: true, + RequestBody: true, + ResponseHeader: true, + ResponseBody: true, + } + var buf bytes.Buffer + logger.SetOutput(&buf) + client := ts.Client() + client.Transport = logger.RoundTripper(client.Transport) + + req, err := http.NewRequest(http.MethodGet, ts.URL, nil) + if err != nil { + t.Errorf("cannot create request: %v", err) + } + req.Host = "127.0.0.1" // hit the IP SAN path; the httptest cert has 127.0.0.1 in IPAddresses + req.Header.Add("User-Agent", "Robot/0.1 crawler@example.com") + resp, err := client.Do(req) + if err != nil { + t.Errorf("cannot connect to the server: %v", err) + } + defer resp.Body.Close() + want := fmt.Sprintf(golden(t.Name()), ts.URL) + if got := buf.String(); !regexp.MustCompile(want).MatchString(got) { + t.Errorf("logged HTTP request %s; want %s", got, want) + } + testBody(t, resp.Body, []byte("Hello, world!")) +} + func TestOutgoingTLSInsecureSkipVerify(t *testing.T) { t.Parallel() ts := httptest.NewTLSServer(&helloHandler{}) diff --git a/printer.go b/printer.go index 1a59fc0..653a76b 100644 --- a/printer.go +++ b/printer.go @@ -371,13 +371,24 @@ func (p *printer) printCertificate(hostname string, cert *x509.Certificate) { p.printf(`* subject: %v * start date: %v * expire date: %v -* issuer: %v `, p.format(color.FgBlue, cert.Subject), p.format(color.FgBlue, cert.NotBefore.Format(time.UnixDate)), p.format(color.FgBlue, cert.NotAfter.Format(time.UnixDate)), - p.format(color.FgBlue, cert.Issuer), ) + if hostname != "" { + if san, ok := matchedSAN(hostname, cert); ok { + if san == "" { + p.printf("* subjectAltName: \"%s\" matches cert's IP address!\n", + p.format(color.FgBlue, hostname)) + } else { + p.printf("* subjectAltName: \"%s\" matches cert's \"%s\"\n", + p.format(color.FgBlue, hostname), + p.format(color.FgBlue, san)) + } + } + } + p.printf("* issuer: %v\n", p.format(color.FgBlue, cert.Issuer)) if hostname == "" { return } @@ -388,6 +399,48 @@ func (p *printer) printCertificate(hostname string, cert *x509.Certificate) { p.println("* TLS certificate verify ok.") } +// matchedSAN finds the cert SAN entry that matches hostname, following the +// RFC 6125 wildcard rule (leftmost label only). For IP-literal hostnames it +// scans IPAddresses and returns "" with ok=true to signal an IP match. +func matchedSAN(hostname string, cert *x509.Certificate) (string, bool) { + if ip := net.ParseIP(hostname); ip != nil { + for _, certIP := range cert.IPAddresses { + if certIP.Equal(ip) { + return "", true + } + } + return "", false + } + host := strings.TrimSuffix(strings.ToLower(hostname), ".") + for _, name := range cert.DNSNames { + if matchHostname(strings.ToLower(name), host) { + return name, true + } + } + return "", false +} + +func matchHostname(pattern, host string) bool { + pattern = strings.TrimSuffix(pattern, ".") + if pattern == "" || host == "" { + return false + } + patternParts := strings.Split(pattern, ".") + hostParts := strings.Split(host, ".") + if len(patternParts) != len(hostParts) { + return false + } + for i, part := range patternParts { + if i == 0 && part == "*" { + continue + } + if part != hostParts[i] { + return false + } + } + return true +} + func (p *printer) printServerResponse(req *http.Request, rec *responseRecorder) { if p.logger.ResponseHeader { // TODO(henvic): see how httptest.ResponseRecorder adds extra headers due to Content-Type detection diff --git a/testdata/log.txtar b/testdata/log.txtar index 13d36d3..4258d15 100644 --- a/testdata/log.txtar +++ b/testdata/log.txtar @@ -505,6 +505,7 @@ form received \* subject: CN=localhost,OU=Cloud,O=Plifk,L=Carmel-by-the-Sea,ST=California,C=US \* start date: Wed Aug 12 22:20:45 UTC 2020 \* expire date: Fri Jul 19 22:20:45 UTC 2120 +\* subjectAltName: "localhost" matches cert's "localhost" \* issuer: CN=localhost,OU=Cloud,O=Plifk,L=Carmel-by-the-Sea,ST=California,C=US \* TLS certificate verify ok\. < HTTP/2\.0 200 OK @@ -626,6 +627,7 @@ Hello, world! \* subject: O=Acme Co \* start date: Thu Jan 1 00:00:00 UTC 1970 \* expire date: Sat Jan 29 16:00:00 UTC 2084 +\* subjectAltName: "example\.com" matches cert's "example\.com" \* issuer: O=Acme Co \* TLS certificate verify ok\. < HTTP/1\.1 200 OK @@ -645,6 +647,25 @@ Hello, world! > User-Agent: Robot/0.1 crawler@example.com * remote error: tls: %s +-- TestOutgoingTLSIPSAN -- +^\* Request to %s +> GET / HTTP/1\.1 +> Host: 127\.0\.0\.1 +> User-Agent: Robot/0\.1 crawler@example\.com + +\* TLS connection using TLS \d+\.\d+ / \w+ +\* Server certificate: +\* subject: O=Acme Co +\* start date: Thu Jan 1 00:00:00 UTC 1970 +\* expire date: Sat Jan 29 16:00:00 UTC 2084 +\* subjectAltName: "127\.0\.0\.1" matches cert's IP address! +\* issuer: O=Acme Co +\* TLS certificate verify ok\. +< HTTP/1\.1 200 OK +< Content-Length: 13 +< Content-Type: text/plain; charset=utf-8 + +Hello, world! -- TestOutgoingTLSInsecureSkipVerify -- ^\* Request to %s \* Skipping TLS verification: connection is susceptible to man-in-the-middle attacks\. @@ -657,6 +678,7 @@ Hello, world! \* subject: O=Acme Co \* start date: Thu Jan 1 00:00:00 UTC 1970 \* expire date: Sat Jan 29 16:00:00 UTC 2084 +\* subjectAltName: "example\.com" matches cert's "example\.com" \* issuer: O=Acme Co \* TLS certificate verify ok\. < HTTP/1\.1 200 OK From 3c12804383d92e72fbc92c79114ddf674883e816 Mon Sep 17 00:00:00 2001 From: Henrique Vicente Date: Thu, 21 May 2026 01:50:44 +0200 Subject: [PATCH 06/10] feat: add http.Flusher and http.ResponseController support to Middleware - Implements Unwrap() on responseRecorder to support http.ResponseController (Go 1.20+). - Let handlers stream via direct w.(http.Flusher) assertions when the underlying writer supports it. --- httpretty.go | 6 ++- recorder.go | 17 +++++++ server_test.go | 134 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/httpretty.go b/httpretty.go index 3bedebb..59f3592 100644 --- a/httpretty.go +++ b/httpretty.go @@ -364,7 +364,11 @@ func (h httpHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { buf: &bytes.Buffer{}, } defer p.printServerResponse(req, rec) - h.next.ServeHTTP(rec, req) + var rw http.ResponseWriter = rec + if _, ok := w.(http.Flusher); ok { + rw = &flushingRecorder{rec} + } + h.next.ServeHTTP(rw, req) } // PrintRequest prints a request, even when WithHide is used to hide it. diff --git a/recorder.go b/recorder.go index 9b55a65..7079027 100644 --- a/recorder.go +++ b/recorder.go @@ -51,3 +51,20 @@ func (rr *responseRecorder) WriteHeader(statusCode int) { rr.ResponseWriter.WriteHeader(statusCode) rr.statusCode = statusCode } + +// Unwrap returns the underlying ResponseWriter so that callers using +// http.NewResponseController can reach interfaces (Flusher, Hijacker, +// Pusher, deadline setters) implemented by the original writer. +func (rr *responseRecorder) Unwrap() http.ResponseWriter { + return rr.ResponseWriter +} + +// flushingRecorder is used only when the underlying writer implements +// http.Flusher, so that rw.(http.Flusher) works. +type flushingRecorder struct { + *responseRecorder +} + +func (fr *flushingRecorder) Flush() { + fr.ResponseWriter.(http.Flusher).Flush() +} diff --git a/server_test.go b/server_test.go index d4b1475..d58129e 100644 --- a/server_test.go +++ b/server_test.go @@ -833,6 +833,140 @@ func TestIncomingBinaryResponseTextRequest(t *testing.T) { } } +func TestIncomingFlusher(t *testing.T) { + t.Parallel() + logger := &Logger{ + ResponseHeader: true, + ResponseBody: true, + } + var buf bytes.Buffer + logger.SetOutput(&buf) + is := inspect(logger.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header()["Date"] = nil + w.Header().Set("Content-Type", "text/plain") + // Flush must not panic when the middleware wraps the ResponseWriter. + if f, ok := w.(http.Flusher); ok { + fmt.Fprint(w, "streamed") + f.Flush() + } else { + t.Error("expected ResponseWriter to implement http.Flusher") + } + })), 1) + + ts := httptest.NewServer(is) + defer ts.Close() + go func() { + client := newServerClient() + resp, err := client.Get(ts.URL) + if err != nil { + t.Errorf("cannot connect to the server: %v", err) + } + defer resp.Body.Close() + testBody(t, resp.Body, []byte("streamed")) + }() + is.Wait() + got := buf.String() + if !strings.Contains(got, "200 OK") { + t.Errorf("expected 200 OK in output, got:\n%s", got) + } + if !strings.Contains(got, "streamed") { + t.Errorf("expected streamed body in output, got:\n%s", got) + } +} + +// plainWriter is a minimal http.ResponseWriter that does not implement +// http.Flusher, used to verify the middleware does not falsely advertise +// flushing when the underlying writer cannot. +type plainWriter struct { + h http.Header + statusCode int + body bytes.Buffer +} + +func (p *plainWriter) Header() http.Header { + if p.h == nil { + p.h = http.Header{} + } + return p.h +} +func (p *plainWriter) Write(b []byte) (int, error) { return p.body.Write(b) } +func (p *plainWriter) WriteHeader(c int) { p.statusCode = c } + +func TestIncomingNonFlushableUnderlying(t *testing.T) { + t.Parallel() + logger := &Logger{ + ResponseHeader: true, + ResponseBody: true, + } + var logBuf bytes.Buffer + logger.SetOutput(&logBuf) + + var sawFlusher bool + var rcFlushErr error + handler := logger.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, sawFlusher = w.(http.Flusher) + rcFlushErr = http.NewResponseController(w).Flush() + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, "ok") + })) + + pw := &plainWriter{} + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + handler.ServeHTTP(pw, req) + + if sawFlusher { + t.Error("inner handler should not see http.Flusher when underlying is not flushable") + } + if rcFlushErr == nil { + t.Error("expected http.NewResponseController(w).Flush() to return an error") + } + // A non-flushable writer must not stop the response: it still reaches the + // client and is recorded by the middleware. + if got := pw.body.String(); got != "ok" { + t.Errorf("underlying writer body = %q, want %q", got, "ok") + } + if got := logBuf.String(); !strings.Contains(got, "ok") { + t.Errorf("expected recorded body in log output, got:\n%s", got) + } +} + +func TestIncomingResponseController(t *testing.T) { + t.Parallel() + logger := &Logger{ + ResponseHeader: true, + ResponseBody: true, + } + var buf bytes.Buffer + logger.SetOutput(&buf) + is := inspect(logger.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header()["Date"] = nil + w.Header().Set("Content-Type", "text/plain") + rc := http.NewResponseController(w) + fmt.Fprint(w, "streamed") + if err := rc.Flush(); err != nil { + t.Errorf("rc.Flush(): %v", err) + } + // SetReadDeadline is not on the wrapper; the controller must walk + // Unwrap to reach it on the underlying writer. + if err := rc.SetReadDeadline(time.Time{}); err != nil { + t.Errorf("rc.SetReadDeadline(): %v", err) + } + })), 1) + + ts := httptest.NewServer(is) + defer ts.Close() + go func() { + client := newServerClient() + resp, err := client.Get(ts.URL) + if err != nil { + t.Errorf("cannot connect to the server: %v", err) + } + defer resp.Body.Close() + testBody(t, resp.Body, []byte("streamed")) + }() + is.Wait() +} + func TestIncomingLongRequest(t *testing.T) { t.Parallel() logger := &Logger{ From d81a3d803b23dceea6281768bd72508180de1c9e Mon Sep 17 00:00:00 2001 From: Henrique Vicente Date: Thu, 21 May 2026 01:51:35 +0200 Subject: [PATCH 07/10] fix: typos --- printer.go | 2 +- server_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/printer.go b/printer.go index 653a76b..8d4b775 100644 --- a/printer.go +++ b/printer.go @@ -111,7 +111,7 @@ func (p *printer) printRequestInfo(req *http.Request) { } } -// checkFilter checkes if the request is filtered and if the Request value is nil. +// checkFilter checks if the request is filtered and if the Request value is nil. func (p *printer) checkFilter(req *http.Request) (skip bool) { filter := p.logger.getFilter() if req == nil { diff --git a/server_test.go b/server_test.go index d58129e..ec14d82 100644 --- a/server_test.go +++ b/server_test.go @@ -105,7 +105,7 @@ func TestIncomingNotFound(t *testing.T) { t.Errorf("cannot connect to the server: %v", err) } if resp.StatusCode != http.StatusNotFound { - t.Errorf("got status codem %v, wanted %v", resp.StatusCode, http.StatusNotFound) + t.Errorf("got status code %v, wanted %v", resp.StatusCode, http.StatusNotFound) } }() is.Wait() From b16323426d98b4b1a025631d33f1448215144323 Mon Sep 17 00:00:00 2001 From: Henrique Vicente Date: Thu, 21 May 2026 02:56:42 +0200 Subject: [PATCH 08/10] feat: color response status line by status class Previously, the response status line was always printed red. Now httpretty picks the color depending on the HTTP status class: 2xx green, 3xx yellow, 4xx red, 5xx bold red, and blue for 1xx. --- httpretty_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ printer.go | 28 +++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/httpretty_test.go b/httpretty_test.go index 5feca64..8ca78f8 100644 --- a/httpretty_test.go +++ b/httpretty_test.go @@ -248,6 +248,46 @@ func TestPrintResponseNil(t *testing.T) { } } +func TestStatusColor(t *testing.T) { + t.Parallel() + // proto is always blue+bold; only the status text color varies by class. + const proto = "\x1b[34;1mHTTP/1.1\x1b[0m" + testCases := []struct { + desc string + status string + want string // expected colored status text, hardcoded + }{ + {"empty status", "", "\x1b[31m\x1b[0m"}, + {"informational 1xx", "100 Continue", "\x1b[34m100 Continue\x1b[0m"}, + {"success 2xx", "200 OK", "\x1b[32m200 OK\x1b[0m"}, + {"redirect 3xx", "301 Moved Permanently", "\x1b[33m301 Moved Permanently\x1b[0m"}, + {"client error 4xx", "404 Not Found", "\x1b[31m404 Not Found\x1b[0m"}, + {"server error 5xx", "500 Internal Server Error", "\x1b[1;31m500 Internal Server Error\x1b[0m"}, + {"non-standard 6xx", "678 Teapot Overheated", "\x1b[34m678 Teapot Overheated\x1b[0m"}, + {"non-numeric status", "OK", "\x1b[31mOK\x1b[0m"}, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + logger := &Logger{ + ResponseHeader: true, + Colors: true, + } + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.PrintResponse(&http.Response{ + Proto: "HTTP/1.1", + Status: tc.status, + Header: http.Header{}, + }) + want := "< " + proto + " " + tc.want + "\n\n" + if got := buf.String(); got != want { + t.Errorf("PrintResponse(%q) status line = %q, want %q", tc.status, got, want) + } + }) + } +} + func testBody(t *testing.T, r io.Reader, want []byte) { t.Helper() got, err := io.ReadAll(r) diff --git a/printer.go b/printer.go index 8d4b775..9cc8e10 100644 --- a/printer.go +++ b/printer.go @@ -468,10 +468,36 @@ func (p *printer) printServerResponse(req *http.Request, rec *responseRecorder) p.printBodyReader(rec.Header().Get("Content-Type"), rec.buf) } +// statusColor returns color attributes for an HTTP status line +// based on the status class: +// 1xx (informational), 2xx (success) is green, 3xx (redirection) is yellow, +// 4xx (client error) is red, and 5xx (server error) is bold red. +// Any non-standard classes (0xx, 6xx-9xx) are blue, +// and an empty status or one that doesn't start with a digit, is shown red. +func statusColor(status string) []color.Attribute { + if len(status) == 0 { + return []color.Attribute{color.FgRed} + } + switch status[0] { + case '2': + return []color.Attribute{color.FgGreen} + case '3': + return []color.Attribute{color.FgYellow} + case '4': + return []color.Attribute{color.FgRed} + case '5': + return []color.Attribute{color.Bold, color.FgRed} + case '0', '1', '6', '7', '8', '9': + return []color.Attribute{color.FgBlue} + default: + return []color.Attribute{color.FgRed} + } +} + func (p *printer) printResponseHeader(proto, status string, h http.Header) { p.printf("< %s %s\n", p.format(color.FgBlue, color.Bold, proto), - p.format(color.FgRed, status)) + p.format(statusColor(status), status)) p.printHeaders('<', h) p.println() } From 01b52264c2d8ce7a30bdc6a9b3d7fa62537687f3 Mon Sep 17 00:00:00 2001 From: Henrique Vicente Date: Thu, 21 May 2026 03:22:48 +0200 Subject: [PATCH 09/10] mod: update minimum required version to Go 1.25 and 1.26 --- .github/workflows/go.yml | 6 +++--- .github/workflows/lint.yml | 2 +- go.mod | 4 ++-- go.sum | 30 ++---------------------------- 4 files changed, 8 insertions(+), 34 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 10652a8..e39286f 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - go: [1.23.x, 1.22.x] # when updating versions, update it below too. + go: [1.25.x, 1.26.x] # when updating versions, update it below too. runs-on: ${{ matrix.os }} name: Test steps: @@ -30,13 +30,13 @@ jobs: # TODO(henvic): Skip generating code coverage when not sending it to Coveralls to speed up testing. # Remove example directory from code coverage explicitly since after #26 it # started being considered on the code coverage report and we don't want that. - continue-on-error: ${{ matrix.os != 'ubuntu-latest' || matrix.go != '1.23.x' }} + continue-on-error: ${{ matrix.os != 'ubuntu-latest' || matrix.go != '1.26.x' }} run: | go test -race -covermode atomic -coverprofile=profile.cov ./... sed -i '/^github\.com\/henvic\/httpretty\/example\//d' profile.cov - name: Code coverage - if: ${{ matrix.os == 'ubuntu-latest' && matrix.go == '1.23.x' }} + if: ${{ matrix.os == 'ubuntu-latest' && matrix.go == '1.26.x' }} uses: shogo82148/actions-goveralls@v1 with: path-to-profile: profile.cov diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 809100e..b9a8329 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Go 1.x uses: actions/setup-go@v6 with: - go-version: "1.23.x" + go-version: "1.26.x" - name: Check out code uses: actions/checkout@v6 diff --git a/go.mod b/go.mod index 8f164c1..63a2a67 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ module github.com/henvic/httpretty -go 1.22 +go 1.25.0 -require golang.org/x/tools v0.14.0 +require golang.org/x/tools v0.45.0 diff --git a/go.sum b/go.sum index abb29e5..46bf538 100644 --- a/go.sum +++ b/go.sum @@ -1,28 +1,2 @@ -github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20= -golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= -golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= -golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= From be052c2024fb72558f861715e3ea4434f859424e Mon Sep 17 00:00:00 2001 From: Henrique Vicente Date: Thu, 21 May 2026 03:23:50 +0200 Subject: [PATCH 10/10] feat: recognize application/gzip as binary type --- printer.go | 1 + 1 file changed, 1 insertion(+) diff --git a/printer.go b/printer.go index 9cc8e10..6fc92a1 100644 --- a/printer.go +++ b/printer.go @@ -243,6 +243,7 @@ var binaryMediatypes = map[string]struct{}{ "video": {}, "application/vnd.ms-fontobject": {}, "font": {}, + "application/gzip": {}, "application/x-gzip": {}, "application/zip": {}, "application/x-rar-compressed": {},