Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions internal/response/body_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package response

import "testing"

func TestParseRawPreservesBodyBytes(t *testing.T) {
for _, body := range []string{" first\r\n\r\nlast \r\n", "\n\n", "\t ", "{\"message\":\"ok\"}\r\n"} {
for _, ending := range []string{"\r\n", "\n"} {
input := "HTTP/1.1 200 OK" + ending + "Content-Type: text/plain" + ending + ending + body
got := Parse(input)
if got.Body != body {
t.Errorf("body %q became %q with header separator %q", body, got.Body, ending)
}
}
}
}
30 changes: 30 additions & 0 deletions internal/response/exchanges_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package response

import (
"strings"
"testing"
)

func TestParseVerboseKeepsFinalExchange(t *testing.T) {
input := strings.Join([]string{
"> GET /old HTTP/1.1", "> Host: old.example", "< HTTP/1.1 302 Found", "< Location: https://new.example/", "<", "redirect body",
"> GET /new HTTP/2", "> Host: new.example", "> X-Protocol: foo HTTP/2", "< HTTP/2 200", "< Content-Type: text/plain", "<", "final body",
}, "\n")
got := Parse(input)
if got.StatusLine != "HTTP/2 200" || got.RequestLine != "GET /new HTTP/2" || got.Body != "final body" {
t.Errorf("mixed exchanges: %#v", got)
}
if len(got.Headers) != 1 || got.Headers[0].Name != "Content-Type" {
t.Errorf("stale response headers: %v", got.Headers)
}
if len(got.RequestHeaders) != 2 || got.RequestHeaders[0].Value != "new.example" {
t.Errorf("stale request headers: %v", got.RequestHeaders)
}
}

func TestParseVerboseDiscardsInterimHeaders(t *testing.T) {
got := Parse("< HTTP/1.1 103 Early Hints\n< Link: </style.css>\n<\n< HTTP/1.1 200 OK\n< Content-Type: text/plain\n<\nbody")
if got.StatusLine != "HTTP/1.1 200 OK" || len(got.Headers) != 1 || got.Headers[0].Name != "Content-Type" || got.Body != "body" {
t.Fatalf("mixed interim and final response: %#v", got)
}
}
19 changes: 19 additions & 0 deletions internal/response/header_name_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package response

import "testing"

func TestParseDoesNotConsumeJSONAsHeaders(t *testing.T) {
for _, input := range []string{`{"message":"ok"}`, "{\n \"message\": \"ok\"\n}\n", "not a header: body\n", ": value\n"} {
got := Parse(input)
if len(got.Headers) != 0 || got.Body != input {
t.Errorf("input %q: headers=%v body=%q", input, got.Headers, got.Body)
}
}
}

func TestParseAcceptsTokenHeaderNames(t *testing.T) {
got := Parse("HTTP/1.1 200 OK\nX-Trace_1: yes\nX!#$%&'*+-.^_`|~: token\n\nbody")
if len(got.Headers) != 2 || got.Body != "body" {
t.Fatalf("unexpected response: %#v", got)
}
}
30 changes: 23 additions & 7 deletions internal/response/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,11 @@ type Header struct {
}

func Parse(input string) Response {
cleaned := strings.ReplaceAll(input, "\r\n", "\n")
cleaned = strings.TrimSpace(cleaned)
if cleaned == "" {
if strings.TrimSpace(input) == "" {
return Response{Mode: "empty", Warnings: []string{"input is empty"}}
}

lines := strings.Split(cleaned, "\n")
lines := strings.Split(input, "\n")
if looksVerbose(lines) {
return parseVerbose(lines)
}
Expand All @@ -53,7 +51,7 @@ func Parse(input string) Response {
}

name, value, found := strings.Cut(line, ":")
if !found {
if !found || !validHeaderName(strings.TrimSpace(name)) {
break
}

Expand All @@ -75,6 +73,20 @@ func Parse(input string) Response {
return response
}

// Header names are nonempty HTTP tokens; JSON keys and body prose are not headers.
func validHeaderName(name string) bool {
if name == "" {
return false
}
for _, r := range name {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", r) {
continue
}
return false
}
return true
}

func FromHTTPResponse(statusLine string, headers http.Header, body []byte) Response {
response := Response{Mode: "http", StatusLine: statusLine}

Expand All @@ -95,7 +107,7 @@ func FromHTTPResponse(statusLine string, headers http.Header, body []byte) Respo
func FormatBody(body []byte, contentType string) string {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return ""
return string(body)
}

if isJSONContentType(contentType) || json.Valid(trimmed) {
Expand Down Expand Up @@ -212,8 +224,10 @@ func parseVerbose(lines []string) Response {
if payload == "" {
continue
}
if response.RequestLine == "" && strings.Contains(payload, " HTTP/") {
fields := strings.Fields(payload)
if len(fields) == 3 && validHeaderName(fields[0]) && strings.HasPrefix(fields[2], "HTTP/") {
response.RequestLine = payload
response.RequestHeaders = nil
continue
}
if name, value, ok := strings.Cut(payload, ":"); ok {
Expand All @@ -231,6 +245,8 @@ func parseVerbose(lines []string) Response {
}
if strings.HasPrefix(payload, "HTTP/") {
response.StatusLine = payload
response.Headers = nil
response.Body = ""
inResponseHeaders = true
inResponseBody = false
continue
Expand Down
22 changes: 22 additions & 0 deletions internal/response/whitespace_body_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package response

import (
"net/http"
"testing"
)

func TestFromHTTPResponsePreservesWhitespaceOnlyBody(t *testing.T) {
for _, body := range []string{" ", "\t\r\n", "\n\n"} {
result := FromHTTPResponse("HTTP/1.1 200 OK", http.Header{"Content-Type": []string{"text/plain"}}, []byte(body))
if result.Body != body {
t.Errorf("body %q became %q", body, result.Body)
}
if len(result.Warnings) != 0 {
t.Errorf("nonempty body reported empty: %v", result.Warnings)
}
}
empty := FromHTTPResponse("HTTP/1.1 204 No Content", nil, nil)
if empty.Body != "" || len(empty.Warnings) == 0 {
t.Fatal("truly empty body should retain its warning")
}
}
Loading