// Package parser holds the very small amount of HTTP parsing we need.
// We don't want to bring in net/http because we only ever see partial
// buffers, mid-stream, often cut in awkward places.
//
// See mercemay.top/src/httptap/
package parser
import (
"bytes"
"strconv"
"strings"
)
// Request captures the start-line of an HTTP/1.x request.
type Request struct {
Method string
Target string
Version string
Headers map[string]string
}
// Response captures the start-line of an HTTP/1.x response.
type Response struct {
Version string
Status int
Reason string
Headers map[string]string
Chunked bool
}
// ParseRequest returns a Request if b starts with a plausible HTTP
// request line. It only looks at what it needs.
func ParseRequest(b []byte) (Request, bool) {
line, rest, ok := splitLine(b)
if !ok {
return Request{}, false
}
parts := strings.SplitN(line, " ", 3)
if len(parts) != 3 {
return Request{}, false
}
if !isMethod(parts[0]) {
return Request{}, false
}
if !strings.HasPrefix(parts[2], "HTTP/") {
return Request{}, false
}
return Request{
Method: parts[0],
Target: parts[1],
Version: parts[2],
Headers: parseHeaders(rest),
}, true
}
// ParseResponse returns a Response if b starts with a plausible HTTP
// status line.
func ParseResponse(b []byte) (Response, bool) {
line, rest, ok := splitLine(b)
if !ok {
return Response{}, false
}
parts := strings.SplitN(line, " ", 3)
if len(parts) < 2 || !strings.HasPrefix(parts[0], "HTTP/") {
return Response{}, false
}
code, err := strconv.Atoi(parts[1])
if err != nil || code < 100 || code > 599 {
return Response{}, false
}
reason := ""
if len(parts) == 3 {
reason = parts[2]
}
hdr := parseHeaders(rest)
return Response{
Version: parts[0],
Status: code,
Reason: reason,
Headers: hdr,
Chunked: strings.EqualFold(hdr["Transfer-Encoding"], "chunked"),
}, true
}
func splitLine(b []byte) (line string, rest []byte, ok bool) {
i := bytes.Index(b, []byte("\r\n"))
if i < 0 {
return "", nil, false
}
return string(b[:i]), b[i+2:], true
}
func parseHeaders(b []byte) map[string]string {
h := make(map[string]string, 8)
for len(b) > 0 {
line, rest, ok := splitLine(b)
if !ok || line == "" {
return h
}
b = rest
idx := strings.IndexByte(line, ':')
if idx < 0 {
continue
}
name := strings.TrimSpace(line[:idx])
value := strings.TrimSpace(line[idx+1:])
// normalise to canonical casing
h[canonicalise(name)] = value
}
return h
}
func canonicalise(s string) string {
out := make([]byte, len(s))
upper := true
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case upper && c >= 'a' && c <= 'z':
out[i] = c - 32
case !upper && c >= 'A' && c <= 'Z':
out[i] = c + 32
default:
out[i] = c
}
upper = c == '-'
}
return string(out)
}
func isMethod(s string) bool {
switch s {
case "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT", "TRACE":
return true
}
return false
}