first Multipart approach

This commit is contained in:
Patricio Whittingslow
2026-07-26 17:22:09 -03:00
parent dd7c4bb037
commit 4f1a178f29
9 changed files with 528 additions and 15 deletions
+32
View File
@@ -4,6 +4,7 @@ import (
"io"
"testing"
"github.com/soypat/lneto/http/httpraw"
"github.com/soypat/lneto/internal"
)
@@ -95,3 +96,34 @@ func BenchmarkHandle(b *testing.B) {
})
}
}
// benchForm is package level so the Form's pair slice is reused across requests,
// as a real handler holding one per goroutine would.
var benchForm httpraw.Form
// BenchmarkRequestParseForm measures reading and parsing a urlencoded body into
// a buffer the caller owns. Nothing on the path may allocate.
func BenchmarkRequestParseForm(b *testing.B) {
const request = "POST /f HTTP/1.1\r\nHost: tinygo.org\r\n" +
"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: 27\r\n\r\n" +
"user=gopher&msg=hello+world"
buf := make([]byte, 64)
var mux MuxSlice
mux.Handle("POST /f", func(ex *Exchange) {
err := ex.RequestParseForm(&benchForm, buf, nopBackoff)
if err != nil || benchForm.Len() != 2 {
panic("invalid result")
}
})
conn := &benchConn{request: request}
exch := benchExchange(b, conn)
b.ReportAllocs()
b.SetBytes(int64(len(request)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
conn.rewind()
exch.Release()
exch.Acquire(conn)
Handle(exch, &mux, nopBackoff)
}
}
+116
View File
@@ -380,6 +380,122 @@ func (exch *Exchange) RequestParseCookie(dst *httpraw.Cookie, key string) error
return dst.ParseBytes(value)
}
// RequestContentType returns the request's Content-Type field value as it
// appears on the wire, parameters included, nil if absent. Test it with
// [httpraw.MediaTypeIs] and pick parameters out with [httpraw.ContentParam].
func (exch *Exchange) RequestContentType() []byte {
return exch.RequestHeader("Content-Type")
}
// RequestContentLength returns the body length declared by the request's
// Content-Length field. An absent field is not a client error: such a request
// has no body at all, RFC 9112 6.3. Check for the error to answer 411 instead.
// See [httpraw.Header.ContentLength].
func (exch *Exchange) RequestContentLength() (int64, error) {
return exch.RequestHeaderRaw().ContentLength()
}
// RequestParseForm reads the request body into buf and parses it as
// "application/x-www-form-urlencoded" into dst. buf is the only storage used and
// the only limit: a body longer than buf is refused with [lneto.ErrBufferFull]
// before a single byte is read, leaving the caller free to answer 413. Pairs are
// left as they arrived, call [httpraw.Form.Decode] to decode them in place.
//
// Unlike http.Request.ParseForm the query string is not folded in, reach it with
// [Exchange.RequestQuery] or [Exchange.AppendQuery]. The body is consumed, so
// call this before [Exchange.ReadBody].
//
// A request with no Content-Length has no body, RFC 9112 6.3, and yields an
// empty form. Use [Exchange.RequestContentLength] to tell that apart from a body
// that arrived empty. backoff paces reads that return no data, as in [Handle].
func (exch *Exchange) RequestParseForm(dst *httpraw.Form, buf []byte, backoff lneto.BackoffStrategy) error {
if !httpraw.MediaTypeIs(exch.RequestContentType(), "application/x-www-form-urlencoded") {
return errNotFormEncoded
} else if exch.RequestHeader("Transfer-Encoding") != nil {
// Chunked bodies are framed, so reading Content-Length bytes off the
// wire would parse chunk sizes as form data. httpraw does not decode them.
return errUnsupportedTransferCoding
}
length, err := exch.RequestContentLength()
if err != nil {
dst.Reset(buf[:0])
return dst.Parse() // No length is no body, RFC 9112 6.3.
} else if length > int64(len(buf)) {
return lneto.ErrBufferFull // Refuse before reading, caller may answer 413.
}
buf = buf[:length]
var consecutiveBackoffs uint
for read := 0; read < len(buf); {
n, err := exch.ReadBody(buf[read:])
if err != nil {
return err
} else if n == 0 {
backoff.Do(consecutiveBackoffs)
consecutiveBackoffs++
continue
}
consecutiveBackoffs = 0
read += n
}
dst.Reset(buf)
return dst.Parse()
}
// RequestParseMultipart prepares dst from the boundary parameter of the
// request's Content-Type field. It reads no body: multipart parts declare no
// length, so the caller drives the loop with a buffer it owns and decides per
// part what to keep and when a part has grown too large.
//
// A part header and the bytes held back by [httpraw.Multipart.NextBody] both ask
// to be completed the same way: compact what is left to the front of the buffer
// and read more in behind it. A buffer that fills without completing either is
// the caller's cue that the part is too large to go on with.
//
// // refill compacts rest to the front of buf and reads more of the body in.
// refill := func(rest []byte) ([]byte, error) {
// n := copy(buf, rest)
// if n == len(buf) {
// return nil, lneto.ErrBufferFull
// }
// nr, err := exch.ReadBody(buf[n:])
// return buf[:n+nr], err
// }
//
// err := exch.RequestParseMultipart(&mp)
// rest := buf[:0]
// for {
// next, err := mp.NextHeader(&hdr, rest)
// if err == io.EOF {
// break // Closing delimiter, body done.
// } else if err == httpraw.ErrNeedMoreData {
// rest, err = refill(rest)
// // ...handle err, then:
// continue
// } else if err != nil {
// return err
// }
// rest = next
// for {
// body, next, done := mp.NextBody(rest)
// // Consume body for hdr.Name, hdr.Filename.
// rest = next
// if done {
// break
// }
// rest, err = refill(rest)
// if err != nil {
// return err
// }
// }
// }
func (exch *Exchange) RequestMultipart() (mp httpraw.Multipart, err error) {
contentType := exch.RequestContentType()
if !httpraw.MediaTypeIs(contentType, "multipart/form-data") {
return mp, errNotMultipart
}
return mp, mp.SetContentType(contentType)
}
// RequestHeader returns the value of the first request header field matching
// key, or nil if absent. Key matching is case sensitive.
func (exch *Exchange) RequestHeader(key string) []byte {
+247
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"io"
"strconv"
"strings"
"testing"
@@ -611,3 +612,249 @@ func TestExchangeAppendQueryReusesBuffer(t *testing.T) {
t.Errorf("AppendQuery allocated %v times into a buffer with capacity, want 0", allocs)
}
}
// formString renders a form as "key=value" pairs joined by '|', a pair with no
// value shown as the bare key.
func formString(f *httpraw.Form) string {
var sb strings.Builder
for i := 0; i < f.Len(); i++ {
if i > 0 {
sb.WriteByte('|')
}
key, value := f.Pair(i)
sb.Write(key)
if value != nil {
sb.WriteByte('=')
sb.Write(value)
}
}
return sb.String()
}
const formType = "Content-Type: application/x-www-form-urlencoded\r\n"
func TestExchangeRequestParseForm(t *testing.T) {
for _, test := range []struct {
name string
request string
bufSize int // Defaults to 64.
want string
wantErr error
}{
{
name: "pairs",
request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 11\r\n\r\na=1&b=2&c=3",
want: "a=1|b=2|c=3",
}, {
// A flag and an empty value stay distinguishable, unlike http.FormValue.
name: "flag and empty",
request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 4\r\n\r\na&b=",
want: "a|b=",
}, {
name: "left encoded",
request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 7\r\n\r\nn=a%20b",
want: "n=a%20b",
}, {
name: "media type parameters",
request: "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: application/x-www-form-urlencoded; charset=utf-8\r\nContent-Length: 3\r\n\r\na=1",
want: "a=1",
}, {
// Only the body is parsed: the query string is not folded in.
name: "query not folded",
request: "POST /f?q=go HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 3\r\n\r\na=1",
want: "a=1",
}, {
// No Content-Length means no body at all, RFC 9112 6.3.
name: "no content length",
request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "\r\n",
want: "",
}, {
name: "wrong media type",
request: "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: text/plain\r\nContent-Length: 3\r\n\r\na=1",
wantErr: errNotFormEncoded,
}, {
name: "no media type",
request: "POST /f HTTP/1.1\r\nHost: h\r\nContent-Length: 3\r\n\r\na=1",
wantErr: errNotFormEncoded,
}, {
name: "chunked",
request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Transfer-Encoding: chunked\r\n\r\n3\r\na=1\r\n0\r\n\r\n",
wantErr: errUnsupportedTransferCoding,
}, {
name: "body larger than buffer",
request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 11\r\n\r\na=1&b=2&c=3",
bufSize: 4,
wantErr: lneto.ErrBufferFull,
},
} {
t.Run(test.name, func(t *testing.T) {
bufSize := test.bufSize
if bufSize == 0 {
bufSize = 64
}
var form httpraw.Form
var gotErr error
var sm MuxSlice
sm.Reset(1)
sm.Handle("/f", func(exch *Exchange) {
gotErr = exch.RequestParseForm(&form, make([]byte, bufSize), nopBackoff)
})
serve(t, test.request, &sm)
if gotErr != test.wantErr {
t.Fatalf("want error %v, got %v", test.wantErr, gotErr)
}
if got := formString(&form); test.wantErr == nil && got != test.want {
t.Errorf("want %q, got %q", test.want, got)
}
})
}
}
// A body arriving after the header, in its own segment, must still be parsed whole.
func TestExchangeRequestParseFormSplit(t *testing.T) {
conn := newConn("POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 11\r\n\r\na=1&")
conn.AddSegment("b=2&c=3")
conn.Hangup()
var form httpraw.Form
var gotErr error
var sm MuxSlice
sm.Reset(1)
sm.Handle("/f", func(exch *Exchange) {
gotErr = exch.RequestParseForm(&form, make([]byte, 64), nopBackoff)
})
exch := newExchange(t, conn, 1024, false)
if err := Handle(exch, &sm, nopBackoff); err != nil {
t.Fatal(err)
}
if gotErr != nil {
t.Fatal(gotErr)
}
if got := formString(&form); got != "a=1|b=2|c=3" {
t.Errorf("want %q, got %q", "a=1|b=2|c=3", got)
}
}
// Decode is the caller's call, and it must reach both keys and values.
func TestExchangeRequestParseFormDecode(t *testing.T) {
var form httpraw.Form
var sm MuxSlice
sm.Reset(1)
sm.Handle("/f", func(exch *Exchange) {
if err := exch.RequestParseForm(&form, make([]byte, 64), nopBackoff); err != nil {
t.Error(err)
} else if err = form.Decode(); err != nil {
t.Error(err)
}
})
serve(t, "POST /f HTTP/1.1\r\nHost: h\r\n"+formType+"Content-Length: 16\r\n\r\na+b=c%20d&e=f%2B", &sm)
if got := formString(&form); got != "a b=c d|e=f+" {
t.Errorf("want %q, got %q", "a b=c d|e=f+", got)
}
}
// refill compacts the bytes the multipart parser held back to the front of buf
// and reads more of the body in behind them.
func refill(exch *Exchange, buf, rest []byte) ([]byte, error) {
n := copy(buf, rest)
if n == len(buf) {
return nil, lneto.ErrBufferFull // A delimiter or part header longer than buf.
}
nr, err := exch.ReadBody(buf[n:])
return buf[:n+nr], err
}
// The whole multipart loop as a handler writes it, over a body split so that a
// part straddles two reads and the caller must compact and refill.
func TestExchangeRequestParseMultipart(t *testing.T) {
const (
boundary = "--xyz"
head = "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: multipart/form-data; boundary=" + boundary + "\r\n\r\n"
part1 = "----xyz\r\nContent-Disposition: form-data; name=\"caption\"\r\n\r\nhi there\r\n"
part2 = "----xyz\r\nContent-Disposition: form-data; name=\"photo\"; filename=\"beach.png\"\r\n\r\n\x89PNG\r\n\x00\r\n"
tail = "----xyz--\r\n"
)
conn := newConn(head + part1 + part2[:20])
conn.AddSegment(part2[20:] + tail)
conn.Hangup()
var got []string
var gotErr error
var sm MuxSlice
sm.Reset(1)
sm.Handle("/f", func(exch *Exchange) {
var mp httpraw.Multipart
if mp, gotErr = exch.RequestMultipart(); gotErr != nil {
return
}
var hdr httpraw.MultipartHeader
buf := make([]byte, 128)
rest := buf[:0]
for {
next, err := mp.NextHeader(&hdr, rest)
if err == io.EOF {
return // Closing delimiter, body done.
} else if err == httpraw.ErrNeedMoreData {
if rest, gotErr = refill(exch, buf, rest); gotErr != nil {
return
}
continue
} else if err != nil {
gotErr = err
return
}
name, total := string(hdr.Name), 0
rest = next
for {
body, next, done := mp.NextBody(rest)
total += len(body)
rest = next
if done {
break
}
if rest, gotErr = refill(exch, buf, rest); gotErr != nil {
return
}
}
got = append(got, name+":"+strconv.Itoa(total))
}
})
exch := newExchange(t, conn, 1024, false)
if err := Handle(exch, &sm, nopBackoff); err != nil {
t.Fatal(err)
}
if gotErr != nil {
t.Fatal(gotErr)
}
const want = "caption:8|photo:7"
if strings.Join(got, "|") != want {
t.Errorf("want %q, got %q", want, strings.Join(got, "|"))
}
}
// A request that is not multipart, or whose boundary is missing, must be refused.
func TestExchangeRequestParseMultipartRejects(t *testing.T) {
for _, test := range []struct {
contentType string
wantErr bool
}{
{contentType: "multipart/form-data; boundary=xyz"},
{contentType: "application/x-www-form-urlencoded", wantErr: true},
{contentType: "multipart/form-data", wantErr: true}, // Boundary is required.
{contentType: "", wantErr: true},
} {
var gotErr error
var sm MuxSlice
sm.Reset(1)
sm.Handle("/f", func(exch *Exchange) {
_, gotErr = exch.RequestMultipart()
})
request := "POST /f HTTP/1.1\r\nHost: h\r\n"
if test.contentType != "" {
request += "Content-Type: " + test.contentType + "\r\n"
}
serve(t, request+"\r\n", &sm)
if (gotErr != nil) != test.wantErr {
t.Errorf("%q: want error %v, got %v", test.contentType, test.wantErr, gotErr)
}
}
}
+4
View File
@@ -22,6 +22,10 @@ var (
errNoRequestProto = errors.New("httphi: request line with no HTTP version")
errBusyExchanges = errors.New("httphi: exchanges still serving, cannot reuse their buffers")
errRouterTornDown = errors.New("httphi: router torn down, configure it before serving")
errNotFormEncoded = errors.New("httphi: request body is not application/x-www-form-urlencoded")
errNotMultipart = errors.New("httphi: request body is not multipart/form-data")
errUnsupportedTransferCoding = errors.New("httphi: transfer coding not decoded, read the body directly")
)
type conn = io.ReadWriteCloser