mirror of
https://github.com/soypat/lneto.git
synced 2026-08-15 12:23:44 +00:00
Further improvements to httphi API (#173)
* add Exchange.WriteBodyString * Mux.MaxPathValues and other improvements * Mux PathValue improvemnt and fixes * MuxSlice more method muxing improvements * diagram out interesting approach to form parsing for clanker * refactor RequestParseForm and achieve greatness in API design * explicit naming of headerCapacityKV value in kvBuffer.Reset * fix Mux bug not matching paths correctly; httpraw HTTP V1 naming applied * rename many examples,use httphi in examples,remove useless maxAwaitingConn field * add ipv4.String * add ipv4 UnspecifiedAddr and BroadcastAddr * add ethernet.String
This commit is contained in:
@@ -24,11 +24,9 @@ mux.Handle("GET /", func(ex *httphi.Exchange) {
|
||||
var router httphi.Router
|
||||
err := router.Configure(httphi.RouterConfig{
|
||||
FixedNumGoroutines: 4, // 4 workers, 4 exchanges, allocated here and never again.
|
||||
MaxAwaitingConns: 8, // Queue depth. Full queue drops connections.
|
||||
RequestHeaderBufferSize: 1024,
|
||||
ResponseHeaderMinBufferSize: 32, // Shares the request buffer.
|
||||
RequestNumHeaderKVCap: 32,
|
||||
Backoff: func(uint) time.Duration { return time.Millisecond },
|
||||
Mux: &mux,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -49,3 +47,8 @@ for {
|
||||
|
||||
Runnable server over raw Linux sockets, plus query, form and multipart handlers:
|
||||
[`example_test.go`](./example_test.go).
|
||||
|
||||
|
||||
## Naming
|
||||
|
||||
Gonna be honest with y'all. I initially wanted it to be named `httplo` until I saw I could write `httphi.MethHead` with a small change.
|
||||
@@ -68,7 +68,7 @@ func BenchmarkHandle(b *testing.B) {
|
||||
request: "GET /?abc=123 HTTP/1.1\r\nHost: tinygo.org\r\nUser-Agent: bench\r\nAccept: */*\r\nConnection: close\r\n\r\n",
|
||||
handler: func(ex *Exchange) {
|
||||
ex.StageHeader("Content-Type", "text/plain")
|
||||
ex.StageHeaderInt("Content-Length", int64(len(benchBody)), 10)
|
||||
ex.StageHeaderIntBase("Content-Length", int64(len(benchBody)), 10)
|
||||
data, present := ex.RequestQueryAppend(buf[:0], "abc", true)
|
||||
if !present || !internal.BytesEqual(data, expect) {
|
||||
panic("invalid result")
|
||||
@@ -112,10 +112,13 @@ 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)
|
||||
// The form owns its memory now, so pre-size it and forbid growth: an
|
||||
// allocation on this path is the failure the benchmark is watching for.
|
||||
benchForm.Reset(make([]byte, 0, 64), 2)
|
||||
benchForm.EnableBufferGrowth(false)
|
||||
var mux MuxSlice
|
||||
mux.Handle("POST /f", func(ex *Exchange) {
|
||||
err := ex.RequestParseForm(&benchForm, buf)
|
||||
err := ex.RequestParseForm(&benchForm, false, false)
|
||||
if err != nil || benchForm.Len() != 2 {
|
||||
panic("invalid result")
|
||||
}
|
||||
|
||||
@@ -77,10 +77,12 @@ func ExampleMuxSlice_query_forms_multipart() {
|
||||
})
|
||||
|
||||
mux.Handle("GET /form", func(ex *httphi.Exchange) {
|
||||
// Request Body Form.
|
||||
formbuf := make([]byte, 1024)
|
||||
// Request Body Form. The form owns the memory: hand it a buffer and
|
||||
// forbid growth to bound what a request may spend.
|
||||
var form httpraw.Form
|
||||
err := ex.RequestParseForm(&form, formbuf)
|
||||
form.Reset(make([]byte, 0, 1024), 8) // Room for 8 pairs.
|
||||
form.EnableBufferGrowth(false)
|
||||
err := ex.RequestParseForm(&form, false, false)
|
||||
if err != nil {
|
||||
ex.WriteHeader(httphi.StatusInternalServerError)
|
||||
return
|
||||
|
||||
+203
-67
@@ -6,6 +6,7 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"unsafe"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/http/httpraw"
|
||||
@@ -33,8 +34,11 @@ type Exchange struct {
|
||||
rawbuf []byte
|
||||
respHeaderOff uint16
|
||||
respHeaderLen uint16
|
||||
reqHdr httpraw.Header
|
||||
pathValues []pathValue
|
||||
reqHdr httpraw.HeaderV1
|
||||
pathValues []PathValue
|
||||
// bodyRW is the reader handed to [httpraw.Form.ReadLimited], kept here so
|
||||
// boxing it into an io.Reader allocates nothing per request.
|
||||
bodyRW ExchangeRW
|
||||
|
||||
hijacked bool
|
||||
rw conn
|
||||
@@ -52,24 +56,28 @@ type Exchange struct {
|
||||
// ExchangeConfig is the memory an [Exchange] is fixed to for the rest of its
|
||||
// life by [Exchange.Configure]. A [Router] derives one per exchange from its
|
||||
// [RouterConfig], which is what bounds the router's memory.
|
||||
//
|
||||
// Fields open with Required, Conditional or Optional and the constraint in
|
||||
// brackets, as in [RouterConfig].
|
||||
type ExchangeConfig struct {
|
||||
// RawBuf is the single buffer holding the request header, the response
|
||||
// Required [non-empty] single buffer holding the request header, the response
|
||||
// header and any surplus body. See [Exchange.UnsafeRawBuffer].
|
||||
RawBuf []byte
|
||||
// RequestBufferLim reserves the first bytes of RawBuf for the request
|
||||
// header, the rest being the response. Configure panics if it exceeds RawBuf.
|
||||
// Required [<=len(RawBuf)] bytes of RawBuf reserved for the request header,
|
||||
// the rest being the response. Configure panics if it exceeds RawBuf.
|
||||
RequestBufferLim int
|
||||
// NumHeaderKVCap is how many request header fields may be parsed. A request
|
||||
// carrying more is answered 431, see [httpraw.ErrHeaderTooMany].
|
||||
// Required [>0] request header fields that may be parsed. A request carrying
|
||||
// more is answered 431, see [httpraw.ErrHeaderTooMany].
|
||||
NumHeaderKVCap int
|
||||
// NormalizeOutgoingKeys normalizes staged response header keys as they are
|
||||
// Optional [any] normalization of staged response header keys as they are
|
||||
// written, i.e: "content-type" becomes "Content-Type".
|
||||
NormalizeOutgoingKeys bool
|
||||
// NoRequestBufferGrowth holds the request header to RequestBufferLim rather
|
||||
// than growing it. A header outgrowing it is answered 431, see [httpraw.ErrBufferExhausted].
|
||||
// Optional [any] cap holding the request header to RequestBufferLim rather than
|
||||
// growing it. A header outgrowing it is answered 431, see [httpraw.ErrBufferExhausted].
|
||||
NoRequestBufferGrowth bool
|
||||
// MaxPathValues is how many wildcards a single pattern may bind, read back with
|
||||
// [Exchange.PathValue]. A pattern with more never matches, see [SetPathValues].
|
||||
// Conditional [>=the most wildcards any one registered pattern binds] number of
|
||||
// path values bindable, read back with [Exchange.PathValue]. A pattern binding
|
||||
// more never matches, see [SetPathValues]. Zero suits a mux of literal patterns.
|
||||
MaxPathValues int
|
||||
}
|
||||
|
||||
@@ -163,13 +171,18 @@ func (exch *Exchange) Release() {
|
||||
// written to and used without modifying the staged response first line.
|
||||
//
|
||||
// Staging headers will write to this buffer so use mindfully.
|
||||
// To access only the request header buffer portion use [httpraw.Header.BufferRaw] limited
|
||||
// to [httpraw.Header.BufferParsed] as returned by [Exchange.RequestHeaderRaw].
|
||||
// To access only the request header buffer portion use [httpraw.HeaderV1.BufferRaw] limited
|
||||
// to [httpraw.HeaderV1.BufferParsed] as returned by [Exchange.requestHeaderRaw].
|
||||
// Writing to this section will not change the contents read by [Exchange.ReadBody].
|
||||
//
|
||||
// In [Router] context, the size of this buffer is influenced directly by [RouterConfig] HeaderBufferSize fields.
|
||||
func (exch *Exchange) UnsafeRawBuffer() []byte { return exch.rawbuf }
|
||||
|
||||
// RequestHeaderV1Raw returns the parsed request header for access beyond the
|
||||
// Request* methods, such as [httpraw.HeaderV1.ForEach]. Valid until the exchange
|
||||
// is released, and writing to it corrupts the response.
|
||||
func (exch *Exchange) RequestHeaderV1Raw() *httpraw.HeaderV1 { return &exch.reqHdr }
|
||||
|
||||
// StageHeader stages a response header field, written on the first
|
||||
// [Exchange.FlushHeader], [Exchange.WriteHeader] or [Exchange.WriteBody].
|
||||
// Returns false and drops the field if the response buffer cannot fit it.
|
||||
@@ -200,11 +213,23 @@ func (exch *Exchange) StageHeader(key, value string) (enoughMemory bool) {
|
||||
return true
|
||||
}
|
||||
|
||||
// StageHeaderInt is [Exchange.StageHeader] with an integer value, i.e: Content-Length.
|
||||
// StageHeaderBytes is [Exchange.StageHeader] with a byte slice value, i.e: a
|
||||
// field copied out of the request. The value is not retained.
|
||||
func (exch *Exchange) StageHeaderBytes(key string, value []byte) (enoughMemory bool) {
|
||||
return exch.StageHeader(key, b2s(value))
|
||||
}
|
||||
|
||||
// StageHeaderInt is [Exchange.StageHeaderIntBase] in base 10, which is the base
|
||||
// every HTTP field value carrying a number uses, i.e: Content-Length.
|
||||
func (exch *Exchange) StageHeaderInt(key string, value int64) (enoughMemory bool) {
|
||||
return exch.StageHeaderIntBase(key, value, 10)
|
||||
}
|
||||
|
||||
// StageHeaderIntBase is [Exchange.StageHeader] with an integer value, i.e: Content-Length.
|
||||
// It formats the value directly into the response buffer without allocating.
|
||||
// base must be in the range 10..36; lower bases are dropped, no HTTP header
|
||||
// field value is written below base 10.
|
||||
func (exch *Exchange) StageHeaderInt(key string, value int64, base int) (enoughMemory bool) {
|
||||
func (exch *Exchange) StageHeaderIntBase(key string, value int64, base int) (enoughMemory bool) {
|
||||
if exch.headerWritten || base < 10 || base > 36 {
|
||||
return false
|
||||
}
|
||||
@@ -252,11 +277,54 @@ func (exch *Exchange) StageStatus(code int) {
|
||||
|
||||
// WriteHeader sends the status line for code along with the staged header
|
||||
// fields. Only the first call reaches the wire, as in http.ResponseWriter.
|
||||
func (exch *Exchange) WriteHeader(code int) {
|
||||
func (exch *Exchange) WriteHeader(code int) (n int, err error) {
|
||||
if !exch.headerWritten {
|
||||
exch.StageStatus(code)
|
||||
exch.FlushHeader()
|
||||
n, err = exch.FlushHeader()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Respond writes a complete response in one call: Content-Type, a Content-Length
|
||||
// taken from len(body), the status line and the body. An empty contentType
|
||||
// stages no Content-Type field, for a code that carries no entity.
|
||||
//
|
||||
// It also stages "Connection: close", the router serving one exchange per
|
||||
// connection, so a peer never waits on a response that is not coming.
|
||||
//
|
||||
// Returns [Exchange.ResponseError]: staged fields that did not fit and failed
|
||||
// writes are both reported there, so a truncated response cannot pass silently.
|
||||
func (exch *Exchange) Respond(code int, contentType string, body []byte) error {
|
||||
exch.stageResponse(code, contentType, len(body))
|
||||
exch.WriteBody(body) // Reports through respErr, checked below.
|
||||
return exch.respErr
|
||||
}
|
||||
|
||||
// RespondString is [Exchange.Respond] with a string body, saving the conversion.
|
||||
func (exch *Exchange) RespondString(code int, contentType, body string) error {
|
||||
exch.stageResponse(code, contentType, len(body))
|
||||
exch.WriteBodyString(body) // Reports through respErr, checked below.
|
||||
return exch.respErr
|
||||
}
|
||||
|
||||
// stageResponse stages the fields and status line a complete response needs.
|
||||
// Drops are recorded on respErr by the Stage* calls, so [Exchange.WriteBody]
|
||||
// declines to write a partial header afterwards.
|
||||
func (exch *Exchange) stageResponse(code int, contentType string, bodyLen int) {
|
||||
if contentType != "" {
|
||||
exch.StageHeader("Content-Type", contentType)
|
||||
}
|
||||
exch.StageHeaderInt("Content-Length", int64(bodyLen))
|
||||
// One exchange per connection today, so the peer is told not to wait for a
|
||||
// second response on it. Revisit once the router loops exchanges.
|
||||
exch.StageHeader("Connection", "close")
|
||||
exch.StageStatus(code)
|
||||
}
|
||||
|
||||
// ResponseError returns any error encountered during staging of headers or during writing of response.
|
||||
// Provides an ergonomic way of checking if one ran out of buffer space after staging all headers with [Exchange.StageHeader].
|
||||
func (exch *Exchange) ResponseError() error {
|
||||
return exch.respErr
|
||||
}
|
||||
|
||||
// FlushHeader writes the status line and staged header fields to the connection
|
||||
@@ -319,6 +387,14 @@ func (rw *ExchangeRW) Write(buf []byte) (int, error) {
|
||||
return rw.exch.WriteBody(buf)
|
||||
}
|
||||
|
||||
// WriteString wraps [Exchange.WriteBodyString]. Fails if handle no longer valid.
|
||||
func (rw *ExchangeRW) WriteString(s string) (int, error) {
|
||||
if err := rw.validate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rw.exch.WriteBodyString(s)
|
||||
}
|
||||
|
||||
// Read reads request body bytes. See [Exchange.ReadBody].
|
||||
// Fails with [net.ErrClosed] once the handle is no longer valid.
|
||||
func (rw *ExchangeRW) Read(buf []byte) (int, error) {
|
||||
@@ -346,7 +422,13 @@ func (exch *Exchange) ReadWriter(dst *ExchangeRW) {
|
||||
dst.exch = exch
|
||||
}
|
||||
|
||||
// Write writes response body bytes, flushing the header first if the handler
|
||||
// WriteBodyString implements [io.StringWriter] by unsafe conversion.
|
||||
// Most underlying [io.Writer] implementations are TCP transport and not modify/own the underlying buffer.
|
||||
func (exch *Exchange) WriteBodyString(buf string) (int, error) {
|
||||
return exch.WriteBody(unsafe.Slice(unsafe.StringData(buf), len(buf)))
|
||||
}
|
||||
|
||||
// WriteBody writes response body bytes, flushing the header first if the handler
|
||||
// has not written it yet. Once a write to the connection fails the response is
|
||||
// unrecoverable and every later write returns that same error, so a body never
|
||||
// reaches the wire without its header.
|
||||
@@ -401,13 +483,6 @@ func (exch *Exchange) MuxPattern() string {
|
||||
return exch.matchedPattern
|
||||
}
|
||||
|
||||
// RequestHeaderRaw returns the parsed request header for access beyond the
|
||||
// Request* methods, such as [httpraw.Header.ForEach]. Valid until the exchange
|
||||
// is released, and writing to it corrupts the response.
|
||||
func (exch *Exchange) RequestHeaderRaw() *httpraw.Header {
|
||||
return &exch.reqHdr
|
||||
}
|
||||
|
||||
// RequestParseCookie parses the request's key header field into dst, i.e:
|
||||
// "Cookie". The caller owns dst and its buffer, so it may be reused between
|
||||
// requests.
|
||||
@@ -422,62 +497,119 @@ func (exch *Exchange) RequestParseCookie(dst *httpraw.Cookie, key string) error
|
||||
func (exch *Exchange) RequestContentType() []byte {
|
||||
// Folded: field names are case insensitive and HTTP/2 mandates lowercase, so
|
||||
// a proxy translating h2 to h1 sends "content-type", RFC 9110 5.1.
|
||||
return exch.RequestHeaderRaw().GetFold("Content-Type")
|
||||
return exch.RequestHeaderV1Raw().GetFold("Content-Type")
|
||||
}
|
||||
|
||||
// RequestContentLength returns the body length declared by the request's
|
||||
// Content-Length field. An absent field is signalled with present=false and no error.
|
||||
// See [httpraw.Header.ContentLength].
|
||||
// See [httpraw.HeaderV1.ContentLength].
|
||||
func (exch *Exchange) RequestContentLength() (_ int64, present bool, _ error) {
|
||||
return exch.RequestHeaderRaw().ContentLength()
|
||||
return exch.RequestHeaderV1Raw().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.
|
||||
// RequestParseForm parses "application/x-www-form-urlencoded" pairs into dst
|
||||
// from the request body and, when parseURL is set, from the query string as
|
||||
// well. Pairs are stored 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.RequestQueryAppend]. The body is consumed, so
|
||||
// call this before [Exchange.ReadBody].
|
||||
// dst owns the memory: both sources are read into its buffer and parsed together
|
||||
// once. Hand it a preallocated buffer with [httpraw.Form.Reset] and turn growth
|
||||
// off with [httpraw.Form.EnableBufferGrowth] to bound it, which then reports
|
||||
// [httpraw.ErrBufferExhausted] instead of allocating. It grows by default.
|
||||
//
|
||||
// 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.
|
||||
func (exch *Exchange) RequestParseForm(dst *httpraw.Form, buf []byte) error {
|
||||
if !httpraw.MediaTypeIs(exch.RequestContentType(), "application/x-www-form-urlencoded") {
|
||||
// prioritizeURL reads the query ahead of the body, so a key carried by both
|
||||
// resolves to the query's value: [httpraw.Form.Get] answers with the first pair
|
||||
// holding a key. Both stay readable in wire order through [httpraw.Form.Pair].
|
||||
// The body is consumed, so call this before [Exchange.ReadBody].
|
||||
//
|
||||
// A request with no Content-Length has no body, RFC 9112 6.3, and one with no
|
||||
// Content-Type declares no encoding to parse, RFC 9110 8.3. Neither is an error,
|
||||
// a bodiless POST being legal, and the query is still parsed when asked for. A
|
||||
// Content-Type that is present and not form encoded is [errNotFormEncoded].
|
||||
func (exch *Exchange) RequestParseForm(dst *httpraw.Form, parseURL, prioritizeURL bool) error {
|
||||
dst.Reset(nil, 0) // Reuse whatever buffer dst holds, discarding old pairs.
|
||||
if parseURL && prioritizeURL {
|
||||
if err := exch.readQueryForm(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := exch.readBodyForm(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
if parseURL && !prioritizeURL {
|
||||
if err := exch.readQueryForm(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return dst.Parse()
|
||||
}
|
||||
|
||||
// formSeparator joins two sources inside one form buffer. Shared so appending it
|
||||
// converts no literal per call.
|
||||
var formSeparator = []byte{'&'}
|
||||
|
||||
// readQueryForm appends the request's query string to dst's buffer.
|
||||
func (exch *Exchange) readQueryForm(dst *httpraw.Form) error {
|
||||
query := exch.RequestQuery()
|
||||
if len(query) == 0 {
|
||||
return nil
|
||||
} else if err := separateForm(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return dst.ReadFromBytes(query)
|
||||
}
|
||||
|
||||
// readBodyForm appends the request body to dst's buffer, reading until
|
||||
// Content-Length bytes have arrived.
|
||||
func (exch *Exchange) readBodyForm(dst *httpraw.Form) error {
|
||||
contentType := exch.RequestContentType()
|
||||
if contentType == nil {
|
||||
return nil // No declared encoding is no form, RFC 9110 8.3.
|
||||
} else if !httpraw.MediaTypeIs(contentType, "application/x-www-form-urlencoded") {
|
||||
return errNotFormEncoded
|
||||
} else if exch.RequestHeaderRaw().GetFold("Transfer-Encoding") != nil {
|
||||
} else if exch.RequestHeaderV1Raw().GetFold("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, present, err := exch.RequestContentLength()
|
||||
if !present {
|
||||
dst.Reset(nil, 0)
|
||||
return nil // No length is no body, RFC 9112 6.3.
|
||||
} else if err != nil {
|
||||
if err != nil {
|
||||
return err
|
||||
} else if length > int64(len(buf)) {
|
||||
return lneto.ErrShortBuffer // Refuse before reading, caller may answer 413.
|
||||
} else if !present || length == 0 {
|
||||
return nil // No length is no body, RFC 9112 6.3.
|
||||
}
|
||||
buf = buf[:length]
|
||||
for read := 0; read < len(buf); {
|
||||
n, err := exch.ReadBody(buf[read:])
|
||||
if err = separateForm(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
// Reuse the exchange's own handle: a local would escape when boxed into the
|
||||
// io.Reader [httpraw.Form.ReadLimited] takes, costing an allocation a request.
|
||||
exch.ReadWriter(&exch.bodyRW)
|
||||
// A single read may fall short of the limit, the body arriving a TCP segment
|
||||
// at a time, so read until the declared length is in hand.
|
||||
for read := 0; read < int(length); {
|
||||
n, err := dst.ReadLimited(&exch.bodyRW, int(length)-read)
|
||||
read += n
|
||||
if n == 0 {
|
||||
if err == nil {
|
||||
err = io.ErrNoProgress
|
||||
} else if err == io.EOF {
|
||||
break
|
||||
break // Peer sent less than it declared.
|
||||
}
|
||||
return err
|
||||
} else if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
}
|
||||
dst.Reset(buf, 0)
|
||||
return dst.Parse()
|
||||
return nil
|
||||
}
|
||||
|
||||
// separateForm appends the '&' keeping two sources from merging into one pair,
|
||||
// doing nothing while dst holds no bytes yet.
|
||||
func separateForm(dst *httpraw.Form) error {
|
||||
if dst.BufferUsed() == 0 {
|
||||
return nil
|
||||
}
|
||||
return dst.ReadFromBytes(formSeparator)
|
||||
}
|
||||
|
||||
// RequestMultipart returns a parser prepared from the boundary parameter of the
|
||||
@@ -589,26 +721,26 @@ func (exch *Exchange) ReadMultiparts(dst []MultipartSink, buf []byte, newSink fu
|
||||
// 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 {
|
||||
header := exch.RequestHeaderRaw()
|
||||
header := exch.RequestHeaderV1Raw()
|
||||
return header.Get(key)
|
||||
}
|
||||
|
||||
// RequestTarget returns the request-target (URI) of the request line, i.e:
|
||||
// "/search?q=go". See [httpraw.Header.RequestTarget].
|
||||
// "/search?q=go". See [httpraw.HeaderV1.RequestTarget].
|
||||
func (exch *Exchange) RequestTarget() []byte {
|
||||
return exch.RequestHeaderRaw().RequestTarget()
|
||||
return exch.RequestHeaderV1Raw().RequestTarget()
|
||||
}
|
||||
|
||||
// RequestPath returns the request-target (URI) up to the query string. This is
|
||||
// what the [Mux] matches on, i.e: "/search" for a request to "/search?q=go".
|
||||
func (exch *Exchange) RequestPath() []byte {
|
||||
return exch.RequestHeaderRaw().RequestPath()
|
||||
return exch.RequestHeaderV1Raw().RequestPath()
|
||||
}
|
||||
|
||||
// RequestQuery returns the request's query string as it appears on the wire.
|
||||
// Iterate it with [httpraw.NextQueryPair]. See [httpraw.Header.RequestQuery].
|
||||
// Iterate it with [httpraw.NextQueryPair]. See [httpraw.HeaderV1.RequestQuery].
|
||||
func (exch *Exchange) RequestQuery() []byte {
|
||||
return exch.RequestHeaderRaw().RequestQuery()
|
||||
return exch.RequestHeaderV1Raw().RequestQuery()
|
||||
}
|
||||
|
||||
// RequestQueryValue returns an undecoded view of the first query parameter
|
||||
@@ -694,14 +826,18 @@ func (exch *Exchange) PathValueAppend(dst []byte, key string, decoded bool) ([]b
|
||||
return dst[:base+n], nil
|
||||
}
|
||||
|
||||
// RequestMethod returns the request line's method, i.e: "GET". See
|
||||
// [MethodFromBytes] to compare it against a [Method].
|
||||
func (exch *Exchange) RequestMethod() []byte {
|
||||
return exch.RequestHeaderRaw().Method()
|
||||
// RequestMethod returns the request's [Method] enum.
|
||||
func (exch *Exchange) RequestMethod() Method {
|
||||
return MethodFromBytes(exch.RequestMethodRaw())
|
||||
}
|
||||
|
||||
// RequestMethod returns the request line's method as a []byte view, i.e: "GET".
|
||||
func (exch *Exchange) RequestMethodRaw() []byte {
|
||||
return exch.RequestHeaderV1Raw().Method()
|
||||
}
|
||||
|
||||
// RequestConnectionClose returns true if the client asked for the connection to
|
||||
// be closed after this exchange with a "Connection: close" header field.
|
||||
func (exch *Exchange) RequestConnectionClose() bool {
|
||||
return exch.RequestHeaderRaw().ConnectionClose()
|
||||
return exch.RequestHeaderV1Raw().ConnectionClose()
|
||||
}
|
||||
|
||||
+186
-12
@@ -23,6 +23,10 @@ func nopBackoff(consecutiveBackoffs uint) time.Duration { return lneto.BackoffFl
|
||||
// where it says so.
|
||||
const defaultNumHeaderKVCap = 32
|
||||
|
||||
// defaultKVCap is the pair table size tests hand to [httpraw.Form.Reset], a
|
||||
// bounded form needing room for the pairs it parses. See [httpraw.Form.Reset].
|
||||
const defaultKVCap = 8
|
||||
|
||||
// newExchange returns an Exchange acquired on conn, ready to serve a request.
|
||||
func newExchange(t *testing.T, conn conn, cfg ExchangeConfig) *Exchange {
|
||||
t.Helper()
|
||||
@@ -214,7 +218,7 @@ func TestHandleRequestFields(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
route, _, _ := strings.Cut(test.wantURI, "?") // Mux matches on path.
|
||||
sm.Handle(route, func(ex *Exchange) {
|
||||
gotMethod = string(ex.RequestMethod())
|
||||
gotMethod = string(ex.RequestMethodRaw())
|
||||
gotURI = string(ex.RequestTarget())
|
||||
gotHost = string(ex.RequestHeader("Host"))
|
||||
ex.WriteHeader(200)
|
||||
@@ -317,7 +321,9 @@ func TestHandleHTTP10Served(t *testing.T) {
|
||||
// No registered handler must yield 404, not an empty response.
|
||||
func TestHandleNoHandler(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Handle("GET /", func(ex *Exchange) { t.Error("handler must not run") })
|
||||
// "/{$}" is the root and nothing else; a bare "/" is a catch-all that would
|
||||
// match /nowhere too, see [SetPathValues].
|
||||
sm.Handle("GET /{$}", func(ex *Exchange) { t.Error("handler must not run") })
|
||||
conn := serve(t, "GET /nowhere HTTP/1.1\r\nHost: h\r\n\r\n", &sm)
|
||||
const want = "HTTP/1.1 404 Not Found\r\n\r\n"
|
||||
if got := conn.ViewWritten(); got != want {
|
||||
@@ -580,7 +586,7 @@ func TestExchangeSetHeaderInt(t *testing.T) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
conn := newConn("")
|
||||
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*256), RequestBufferLim: 256})
|
||||
exch.StageHeaderInt("N", test.value, test.base)
|
||||
exch.StageHeaderIntBase("N", test.value, test.base)
|
||||
exch.WriteHeader(200)
|
||||
got, _ := strings.CutPrefix(conn.ViewWritten(), "HTTP/1.1 200 OK\r\n")
|
||||
if got != test.want {
|
||||
@@ -594,7 +600,7 @@ func TestExchangeSetHeaderInt(t *testing.T) {
|
||||
func TestExchangeSetHeaderIntNoAlloc(t *testing.T) {
|
||||
exch := newExchange(t, newConn(""), ExchangeConfig{RawBuf: make([]byte, 2*256), RequestBufferLim: 256})
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
exch.StageHeaderInt("Content-Length", 1234567890, 10)
|
||||
exch.StageHeaderIntBase("Content-Length", 1234567890, 10)
|
||||
})
|
||||
if allocs != 0 {
|
||||
t.Errorf("SetHeaderInt allocated %v times, want 0", allocs)
|
||||
@@ -808,10 +814,13 @@ func TestExchangeRequestParseForm(t *testing.T) {
|
||||
contentType: "text/plain",
|
||||
wantErr: errNotFormEncoded,
|
||||
}, {
|
||||
// An absent field is not a wrong one: no media type is no body, the
|
||||
// same answer "no content length" gets above. Only a type that is
|
||||
// present and not form encoded is an error.
|
||||
name: "no media type",
|
||||
formVals: []formPair{{key: "a", value: "1"}},
|
||||
noContentType: true,
|
||||
wantErr: errNotFormEncoded,
|
||||
wantVals: []formPair{},
|
||||
}, {
|
||||
// The coding is refused on the field alone, so the body stays off.
|
||||
name: "chunked",
|
||||
@@ -819,10 +828,12 @@ func TestExchangeRequestParseForm(t *testing.T) {
|
||||
extraHeaders: "Transfer-Encoding: chunked\r\n",
|
||||
wantErr: errUnsupportedTransferCoding,
|
||||
}, {
|
||||
// The form bounds itself now, so an oversized body is the form
|
||||
// refusing to grow rather than a short buffer handed in.
|
||||
name: "body larger than buffer",
|
||||
formVals: []formPair{{key: "a", value: "1"}, {key: "b", value: "2"}, {key: "c", value: "3"}},
|
||||
bufsize: 4,
|
||||
wantErr: lneto.ErrShortBuffer,
|
||||
wantErr: httpraw.ErrBufferExhausted,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -862,12 +873,16 @@ func TestExchangeRequestParseForm(t *testing.T) {
|
||||
builder.WriteString("\r\n")
|
||||
builder.Write(body)
|
||||
|
||||
// The form owns the memory: bufSize bounds it here, growth off so an
|
||||
// oversized body is reported rather than allocated for.
|
||||
var form httpraw.Form
|
||||
form.Reset(make([]byte, 0, bufSize), defaultKVCap)
|
||||
form.EnableBufferGrowth(false)
|
||||
var gotErr error
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
sm.Handle("/f", func(exch *Exchange) {
|
||||
gotErr = exch.RequestParseForm(&form, make([]byte, bufSize))
|
||||
gotErr = exch.RequestParseForm(&form, false, false)
|
||||
if gotErr == nil && test.callDecode {
|
||||
gotErr = form.Decode()
|
||||
}
|
||||
@@ -910,7 +925,7 @@ func TestExchangeRequestParseFormSplit(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
sm.Handle("/f", func(exch *Exchange) {
|
||||
gotErr = exch.RequestParseForm(&form, make([]byte, 64))
|
||||
gotErr = exch.RequestParseForm(&form, false, false)
|
||||
})
|
||||
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024})
|
||||
if err := Handle(exch, &sm, nopBackoff); err != nil {
|
||||
@@ -930,7 +945,7 @@ func TestExchangeRequestParseFormDecode(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
sm.Handle("/f", func(exch *Exchange) {
|
||||
if err := exch.RequestParseForm(&form, make([]byte, 64)); err != nil {
|
||||
if err := exch.RequestParseForm(&form, false, false); err != nil {
|
||||
t.Error(err)
|
||||
} else if err = form.Decode(); err != nil {
|
||||
t.Error(err)
|
||||
@@ -1267,7 +1282,7 @@ func TestHandleBrowserSizedRequest(t *testing.T) {
|
||||
sm.Reset(1)
|
||||
sm.Handle("GET /echo", func(exch *Exchange) {
|
||||
gotMode = string(exch.RequestHeader("X-Mode"))
|
||||
exch.RequestHeaderRaw().ForEach(func(key, value []byte) bool {
|
||||
exch.RequestHeaderV1Raw().ForEach(func(key, value []byte) bool {
|
||||
fields++
|
||||
return true
|
||||
})
|
||||
@@ -1430,7 +1445,7 @@ func TestExchangeRequestContentTypeFolded(t *testing.T) {
|
||||
sm.Reset(1)
|
||||
sm.Handle("/f", func(exch *Exchange) {
|
||||
gotType = string(exch.RequestContentType())
|
||||
gotErr = exch.RequestParseForm(&form, make([]byte, 64))
|
||||
gotErr = exch.RequestParseForm(&form, false, false)
|
||||
})
|
||||
serve(t, "POST /f HTTP/1.1\r\nHost: h\r\n"+name+": "+formType+"\r\nContent-Length: 3\r\n\r\na=1", &sm)
|
||||
|
||||
@@ -1459,7 +1474,7 @@ func TestExchangeRequestParseFormFoldedTransferEncoding(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
sm.Handle("/f", func(exch *Exchange) {
|
||||
gotErr = exch.RequestParseForm(&form, make([]byte, 64))
|
||||
gotErr = exch.RequestParseForm(&form, false, false)
|
||||
})
|
||||
serve(t, "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: application/x-www-form-urlencoded\r\n"+
|
||||
name+": chunked\r\nContent-Length: "+strconv.Itoa(len(body))+"\r\n\r\n"+body, &sm)
|
||||
@@ -1470,3 +1485,162 @@ func TestExchangeRequestParseFormFoldedTransferEncoding(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Respond replaces the stage/stage/stage/write boilerplate every handler paid,
|
||||
// deriving Content-Length from the body so it cannot disagree with what is sent.
|
||||
func TestExchangeRespond(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
code int
|
||||
contentType string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "html", code: 200, contentType: "text/html", body: "<h1>hi</h1>",
|
||||
want: "HTTP/1.1 200 OK\r\nContent-Type:text/html\r\nContent-Length:11\r\nConnection:close\r\n\r\n<h1>hi</h1>",
|
||||
},
|
||||
{
|
||||
name: "empty body still declares zero length", code: 200, contentType: "text/plain", body: "",
|
||||
want: "HTTP/1.1 200 OK\r\nContent-Type:text/plain\r\nContent-Length:0\r\nConnection:close\r\n\r\n",
|
||||
},
|
||||
{
|
||||
name: "no content type staged when empty", code: 204, contentType: "", body: "",
|
||||
want: "HTTP/1.1 204 No Content\r\nContent-Length:0\r\nConnection:close\r\n\r\n",
|
||||
},
|
||||
{
|
||||
name: "error code carries a body", code: 500, contentType: "text/plain", body: "boom",
|
||||
want: "HTTP/1.1 500 Internal Server Error\r\nContent-Type:text/plain\r\nContent-Length:4\r\nConnection:close\r\n\r\nboom",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name+"/bytes", func(t *testing.T) {
|
||||
conn := newConn("")
|
||||
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 512), RequestBufferLim: 256})
|
||||
if err := exch.Respond(test.code, test.contentType, []byte(test.body)); err != nil {
|
||||
t.Fatalf("Respond: %s", err)
|
||||
}
|
||||
if got := conn.ViewWritten(); got != test.want {
|
||||
t.Errorf("want %q, got %q", test.want, got)
|
||||
}
|
||||
})
|
||||
t.Run(test.name+"/string", func(t *testing.T) {
|
||||
conn := newConn("")
|
||||
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 512), RequestBufferLim: 256})
|
||||
if err := exch.RespondString(test.code, test.contentType, test.body); err != nil {
|
||||
t.Fatalf("RespondString: %s", err)
|
||||
}
|
||||
if got := conn.ViewWritten(); got != test.want {
|
||||
t.Errorf("want %q, got %q", test.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A response that does not fit must be reported, not shipped truncated: the
|
||||
// whole point of folding the boilerplate into one call.
|
||||
func TestExchangeRespondReportsOverflow(t *testing.T) {
|
||||
conn := newConn("")
|
||||
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 64), RequestBufferLim: 32})
|
||||
err := exch.Respond(200, strings.Repeat("t", 200), []byte("body"))
|
||||
if err == nil {
|
||||
t.Fatal("want an error for a response header that cannot fit")
|
||||
}
|
||||
if got := conn.ViewWritten(); got != "" {
|
||||
t.Errorf("nothing must reach the wire, got %q", got)
|
||||
}
|
||||
if exch.ResponseError() == nil {
|
||||
t.Error("want the failure recorded on the exchange too")
|
||||
}
|
||||
}
|
||||
|
||||
// Query and body are read into one form buffer and parsed together, so both
|
||||
// sources are present at once and read order decides which value a key resolves
|
||||
// to. A key carried by both keeps both pairs, in wire order.
|
||||
func TestExchangeRequestParseFormFoldsQuery(t *testing.T) {
|
||||
const body = "cnt=body&only=b"
|
||||
const target = "/f?cnt=query&page=2"
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
parseURL, prioritizeURL bool
|
||||
wantCnt string
|
||||
wantPage string
|
||||
wantRendered string
|
||||
}{
|
||||
{
|
||||
name: "body only", parseURL: false,
|
||||
wantCnt: "body", wantPage: "", wantRendered: "cnt=body|only=b",
|
||||
},
|
||||
{
|
||||
name: "query first wins", parseURL: true, prioritizeURL: true,
|
||||
wantCnt: "query", wantPage: "2", wantRendered: "cnt=query|page=2|cnt=body|only=b",
|
||||
},
|
||||
{
|
||||
name: "body first wins", parseURL: true, prioritizeURL: false,
|
||||
wantCnt: "body", wantPage: "2", wantRendered: "cnt=body|only=b|cnt=query|page=2",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var form httpraw.Form
|
||||
var gotErr error
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
sm.Handle("POST /f", func(exch *Exchange) {
|
||||
gotErr = exch.RequestParseForm(&form, test.parseURL, test.prioritizeURL)
|
||||
})
|
||||
serve(t, "POST "+target+" HTTP/1.1\r\nHost: h\r\n"+
|
||||
"Content-Type: application/x-www-form-urlencoded\r\n"+
|
||||
"Content-Length: "+strconv.Itoa(len(body))+"\r\n\r\n"+body, &sm)
|
||||
if gotErr != nil {
|
||||
t.Fatalf("RequestParseForm: %s", gotErr)
|
||||
}
|
||||
if got := string(form.Get("cnt")); got != test.wantCnt {
|
||||
t.Errorf("want cnt=%q, got %q", test.wantCnt, got)
|
||||
}
|
||||
if got := string(form.Get("page")); got != test.wantPage {
|
||||
t.Errorf("want page=%q, got %q", test.wantPage, got)
|
||||
}
|
||||
if got := formString(&form); got != test.wantRendered {
|
||||
t.Errorf("want pairs %q, got %q", test.wantRendered, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A GET with a query and no body must fold the query alone: no Content-Type
|
||||
// means no body to parse, which is not an error.
|
||||
func TestExchangeRequestParseFormQueryWithoutBody(t *testing.T) {
|
||||
var form httpraw.Form
|
||||
var gotErr error
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
sm.Handle("GET /f", func(exch *Exchange) {
|
||||
gotErr = exch.RequestParseForm(&form, true, true)
|
||||
})
|
||||
serve(t, "GET /f?a=1&b=2 HTTP/1.1\r\nHost: h\r\n\r\n", &sm)
|
||||
if gotErr != nil {
|
||||
t.Fatalf("want the query parsed with no body, got %s", gotErr)
|
||||
}
|
||||
if got := formString(&form); got != "a=1|b=2" {
|
||||
t.Errorf("want a=1|b=2, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The separator must not merge the two sources into one pair: without it the
|
||||
// last query pair and the first body pair run together.
|
||||
func TestExchangeRequestParseFormSourcesNotMerged(t *testing.T) {
|
||||
var form httpraw.Form
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
sm.Handle("POST /f", func(exch *Exchange) {
|
||||
if err := exch.RequestParseForm(&form, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
const body = "second=2"
|
||||
serve(t, "POST /f?first=1 HTTP/1.1\r\nHost: h\r\n"+
|
||||
"Content-Type: application/x-www-form-urlencoded\r\n"+
|
||||
"Content-Length: "+strconv.Itoa(len(body))+"\r\n\r\n"+body, &sm)
|
||||
if got := formString(&form); got != "first=1|second=2" {
|
||||
t.Errorf("want first=1|second=2, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ const (
|
||||
|
||||
// segSizes are the chunk sizes a request may be delivered in, index 0 meaning
|
||||
// "all at once". Splitting a request mid-CRLF or before a colon is what drives
|
||||
// [httpraw.Header.TryParse]'s resumption path. Entries may be appended, never
|
||||
// [httpraw.HeaderV1.TryParse]'s resumption path. Entries may be appended, never
|
||||
// changed: an existing index must keep splitting exactly as it does today.
|
||||
var segSizes = [16]int{0, 1, 2, 3, 5, 7, 11, 16, 23, 37, 64, 101, 173, 256, 509, 1024}
|
||||
|
||||
@@ -152,7 +152,7 @@ func checkResponse(t *testing.T, written string) {
|
||||
if !strings.Contains(written, "\r\n\r\n") {
|
||||
t.Fatalf("header block never terminated: %q", written)
|
||||
}
|
||||
var resp httpraw.Header
|
||||
var resp httpraw.HeaderV1
|
||||
const asResponse = true
|
||||
if err := resp.ParseBytes(asResponse, []byte(written)); err != nil {
|
||||
t.Fatalf("response does not parse back: %s in %q", err, written)
|
||||
@@ -190,7 +190,7 @@ func FuzzHandleRequest(f *testing.F) {
|
||||
// escaping, not this package's framing.
|
||||
for i := range stage {
|
||||
exch.StageHeader("X-Fuzz", "value")
|
||||
exch.StageHeaderInt("X-Fuzz-Int", int64(i), 10)
|
||||
exch.StageHeaderIntBase("X-Fuzz-Int", int64(i), 10)
|
||||
}
|
||||
}
|
||||
if ops&opReadBody != 0 {
|
||||
@@ -251,7 +251,7 @@ func FuzzQueryAndForm(f *testing.F) {
|
||||
|
||||
var form httpraw.Form
|
||||
buf := make([]byte, scratchLen)
|
||||
if err := exch.RequestParseForm(&form, buf); err != nil {
|
||||
if err := exch.RequestParseForm(&form, false, false); err != nil {
|
||||
return
|
||||
}
|
||||
total := 0
|
||||
|
||||
+217
-42
@@ -66,6 +66,7 @@ func Handle(exch *Exchange, mux Mux, backoff lneto.BackoffStrategy) error {
|
||||
// Mux on the request path: the query string is the handler's business.
|
||||
path := reqhdr.RequestPath()
|
||||
meth := reqhdr.Method()
|
||||
clear(exch.pathValues)
|
||||
matchedPattern, handler := mux.LookupHandler(MethodFromBytes(meth), path, exch.pathValues)
|
||||
if handler != nil {
|
||||
exch.matchedPattern = matchedPattern
|
||||
@@ -80,6 +81,18 @@ func Handle(exch *Exchange, mux Mux, backoff lneto.BackoffStrategy) error {
|
||||
}
|
||||
|
||||
func (exch *Exchange) handleError(err error) {
|
||||
if err == lneto.ErrUnsupported {
|
||||
// httpraw refused a first line naming a version it does not speak, before
|
||||
// spending the field loop on it. An empty protocol is a HTTP/0.9
|
||||
// simple-request, RFC 9112 3: a malformed 1.x request-line rather than a
|
||||
// version there is any point naming back.
|
||||
if len(exch.reqHdr.Protocol()) == 0 {
|
||||
exch.WriteHeader(int(StatusBadRequest))
|
||||
} else {
|
||||
exch.WriteHeader(int(StatusHTTPVersionNotSupported))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == httpraw.ErrHeaderTooMany || err == httpraw.ErrBufferExhausted || exch.reqHdr.BufferFree() == 0 {
|
||||
// The peer is owed an answer: no larger buffer is coming, so
|
||||
// say so instead of dropping the connection, RFC 6585 5.
|
||||
@@ -101,22 +114,15 @@ type Mux interface {
|
||||
// LookupHandler matches the requestPath and method to a handler and returns it and the
|
||||
// pattern it matched. dstPathVals are set to non-zero values by Mux and can later be accessed by [Exchange.PathValue]
|
||||
// requestPath is a buffer owned by the [Exchange] usually and should not be held after LookupHandler returns.
|
||||
LookupHandler(get Method, requestPath []byte, dstPathVals []pathValue) (matchedPattern string, handler HandlerFunc)
|
||||
LookupHandler(get Method, requestPath []byte, dstPathVals []PathValue) (matchedPattern string, handler HandlerFunc)
|
||||
// MaxPathValues specifies the required size of dstPathVals in a call to [Mux.LookupHandler].
|
||||
// MaxPathValues should return -1 if no paths have been configured to catch situation
|
||||
// where the Mux has been passed to a [Router.Configuration] before registering paths.
|
||||
MaxPathValues() int
|
||||
}
|
||||
|
||||
// MuxSlice is a [Mux] backed by a slice of registered endpoints, matched by
|
||||
// exact path. Lookup is linear in the number of registrations.
|
||||
type MuxSlice struct {
|
||||
// TODO: binary search worth it?
|
||||
_handlers []struct {
|
||||
method Method
|
||||
path string
|
||||
handler HandlerFunc
|
||||
setPathVal bool
|
||||
}
|
||||
}
|
||||
|
||||
type pathValue struct {
|
||||
// PathValue used to implement [Mux] interface. Stores http.Request.PathValue-like values.
|
||||
type PathValue struct {
|
||||
Key string // owned by mux.
|
||||
Value []byte // points to raw exchange buffer.
|
||||
}
|
||||
@@ -133,17 +139,30 @@ var pathSeparator = []byte{'/'}
|
||||
// Unlike ServeMux, segments are compared and bound raw, so "/users/{id}" binds
|
||||
// "x%2Fy" and not "x/y". Which paths match is unaffected. Bound values alias
|
||||
// requestPath rather than copy it.
|
||||
func SetPathValues(dstPathVals []pathValue, pattern string, requestPath []byte) (matched, pathValSliceTooShort bool) {
|
||||
//
|
||||
// Values are bound while walking, before the match is known, so on failure
|
||||
// SetPathValues clears what it bound. A [Mux] may then try patterns in turn
|
||||
// without a matching one inheriting values from one that failed.
|
||||
func SetPathValues(dstPathVals []PathValue, pattern string, requestPath []byte) (matched, pathValSliceTooShort bool) {
|
||||
n, matched, pathValSliceTooShort := setPathValues(dstPathVals, pattern, requestPath)
|
||||
if !matched {
|
||||
clear(dstPathVals[:n])
|
||||
}
|
||||
return matched, pathValSliceTooShort
|
||||
}
|
||||
|
||||
// setPathValues is [SetPathValues] reporting how many values it bound, so its
|
||||
// caller can discard them when the pattern turns out not to match.
|
||||
func setPathValues(dstPathVals []PathValue, pattern string, requestPath []byte) (n int, matched, pathValSliceTooShort bool) {
|
||||
if len(pattern) == 0 || pattern[0] != '/' || len(requestPath) == 0 || requestPath[0] != '/' {
|
||||
return false, false
|
||||
return n, false, false
|
||||
}
|
||||
pattern, requestPath = pattern[1:], requestPath[1:]
|
||||
n := 0
|
||||
for {
|
||||
if len(pattern) == 0 {
|
||||
// Nothing left after a slash: an anonymous "..." taking the rest,
|
||||
// which is why "/files/" matches "/files/a/b" and "/" matches all.
|
||||
return true, false
|
||||
return n, true, false
|
||||
}
|
||||
patSeg, patRest, patMore := strings.Cut(pattern, "/")
|
||||
reqSeg, reqRest, reqMore := bytes.Cut(requestPath, pathSeparator)
|
||||
@@ -152,40 +171,40 @@ func SetPathValues(dstPathVals []pathValue, pattern string, requestPath []byte)
|
||||
case isWildcard && name == "$":
|
||||
// Matches the end of the path and nothing else, so it must be the
|
||||
// last segment of the pattern and leave no path behind.
|
||||
return !patMore && len(requestPath) == 0, false
|
||||
return n, !patMore && len(requestPath) == 0, false
|
||||
|
||||
case isWildcard && isMulti:
|
||||
// Takes the remainder including slashes, possibly empty.
|
||||
if name != "" {
|
||||
if n >= len(dstPathVals) {
|
||||
return false, true
|
||||
return n, false, true
|
||||
}
|
||||
dstPathVals[n] = pathValue{Key: name, Value: requestPath}
|
||||
dstPathVals[n] = PathValue{Key: name, Value: requestPath}
|
||||
n++
|
||||
}
|
||||
return true, false
|
||||
return n, true, false
|
||||
|
||||
case isWildcard:
|
||||
if len(reqSeg) == 0 {
|
||||
return false, false // One segment means a non-empty one.
|
||||
return n, false, false // One segment means a non-empty one.
|
||||
}
|
||||
if n >= len(dstPathVals) {
|
||||
return false, true
|
||||
return n, false, true
|
||||
}
|
||||
dstPathVals[n] = pathValue{Key: name, Value: reqSeg}
|
||||
dstPathVals[n] = PathValue{Key: name, Value: reqSeg}
|
||||
n++
|
||||
|
||||
default:
|
||||
if b2s(reqSeg) != patSeg {
|
||||
return false, false
|
||||
return n, false, false
|
||||
}
|
||||
}
|
||||
if patMore != reqMore {
|
||||
// One side has a further segment and the other does not, so
|
||||
// "/health" misses "/health/" and "/files/" misses "/files".
|
||||
return false, false
|
||||
return n, false, false
|
||||
} else if !patMore {
|
||||
return true, false // Both spent on the same segment.
|
||||
return n, true, false // Both spent on the same segment.
|
||||
}
|
||||
pattern, requestPath = patRest, reqRest
|
||||
}
|
||||
@@ -205,48 +224,204 @@ func pathWildcard(segment string) (name string, isMulti, ok bool) {
|
||||
return name, false, true
|
||||
}
|
||||
|
||||
// MuxSlice is a [Mux] implementation backed by a slice of registered endpoints, matched by
|
||||
// exact path. Lookup is linear in the number of registrations.
|
||||
type MuxSlice struct {
|
||||
// TODO: binary search worth it?
|
||||
_handlers []struct {
|
||||
method Method
|
||||
path string
|
||||
handler HandlerFunc
|
||||
pathVals int
|
||||
spec int
|
||||
}
|
||||
}
|
||||
|
||||
// Reset discards all registered handlers, reusing the backing array and growing
|
||||
// it to fit capacity registrations.
|
||||
func (sm *MuxSlice) Reset(capacity int) {
|
||||
internal.SliceReuse(&sm._handlers, capacity)
|
||||
}
|
||||
|
||||
// LookupHandler returns the handler registered for request path, or nil if none matches.
|
||||
// The first registration matching both method and uri wins.
|
||||
func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []pathValue) (matched string, _ HandlerFunc) {
|
||||
for _, endpoint := range sm._handlers {
|
||||
// LookupHandler returns the handler registered for request path, or nil if none
|
||||
// matches. The most specific matching registration wins, not the first, so the
|
||||
// catch-all "/" may be registered alongside the endpoints it backs without
|
||||
// shadowing them, as in http.ServeMux, see [patternSpecificity]. Registrations
|
||||
// of equal specificity are resolved in registration order.
|
||||
//
|
||||
// Every method this package does not name is [MethUnknown], so a request with an
|
||||
// extension method matches a bare-path registration and any registration naming
|
||||
// an extension method, whichever it names. Tell PROPFIND from MKCOL inside the
|
||||
// handler with [Exchange.RequestMethodRaw].
|
||||
func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []PathValue) (matched string, _ HandlerFunc) {
|
||||
best := -1
|
||||
bestSpec := 0
|
||||
for i, endpoint := range sm._handlers {
|
||||
if endpoint.method != MethUndefined && endpoint.method != method {
|
||||
continue
|
||||
} else if best >= 0 && endpoint.spec <= bestSpec {
|
||||
continue // Cannot beat the incumbent, so do not pay to match it.
|
||||
}
|
||||
// Method matches.
|
||||
if endpoint.setPathVal {
|
||||
if ok, _ := SetPathValues(dstPathVals, endpoint.path, path); ok {
|
||||
return endpoint.path, endpoint.handler
|
||||
}
|
||||
} else if b2s(path) == endpoint.path {
|
||||
return endpoint.path, endpoint.handler
|
||||
// Method matches. A pattern ending in '/' is a wildcard despite binding no
|
||||
// values: the trailing slash is an anonymous "{...}", so it must go
|
||||
// through the matcher and not a literal compare, see [SetPathValues].
|
||||
var ok bool
|
||||
if isWildcardPattern(endpoint.path) {
|
||||
// dstPathVals is scratch during the scan: a candidate that matches and
|
||||
// is then beaten, or one that is beaten and clears on failure, would
|
||||
// leave the winner's values wrong, so the winner is bound below.
|
||||
ok, _ = SetPathValues(dstPathVals, endpoint.path, path)
|
||||
} else {
|
||||
ok = b2s(path) == endpoint.path
|
||||
}
|
||||
if ok {
|
||||
best, bestSpec = i, endpoint.spec
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
if best < 0 {
|
||||
return "", nil
|
||||
}
|
||||
winner := sm._handlers[best]
|
||||
if isWildcardPattern(winner.path) {
|
||||
clear(dstPathVals) // The scan may have bound more values than the winner does.
|
||||
SetPathValues(dstPathVals, winner.path, path)
|
||||
}
|
||||
return winner.path, winner.handler
|
||||
}
|
||||
|
||||
// MaxPathValues returns the maximum number of path values any endpoint could have.
|
||||
func (sm *MuxSlice) MaxPathValues() (maxPathValues int) {
|
||||
if len(sm._handlers) == 0 {
|
||||
return -1 // Signal no handlers registered.
|
||||
}
|
||||
for _, endpoint := range sm._handlers {
|
||||
maxPathValues = max(maxPathValues, endpoint.pathVals)
|
||||
}
|
||||
return maxPathValues
|
||||
}
|
||||
|
||||
// Handle registers handler for reg, either a bare path matching any method or a
|
||||
// method and path separated by a space, i.e: "/health" or "GET /health".
|
||||
// Handle does not check for duplicate registrations: the first one added wins.
|
||||
//
|
||||
// Handle panics on a registration that could never serve a request: a method
|
||||
// token carrying lowercase (methods are case sensitive and uppercase, RFC 9110
|
||||
// 9.1, so "Get" matches no GET request), a path not rooted at '/', or an exact
|
||||
// duplicate of an earlier registration, which the first one always shadows.
|
||||
// Registration is program startup, so a fault belongs there and not in a
|
||||
// permanent silent 404.
|
||||
func (sm *MuxSlice) Handle(optMethodAndPath string, handler HandlerFunc) {
|
||||
v := internal.SliceReclaim(&sm._handlers)
|
||||
method := MethUndefined
|
||||
methodOrURL, url, methodFound := strings.Cut(optMethodAndPath, " ")
|
||||
if methodFound {
|
||||
if hasLowerASCII(methodOrURL) {
|
||||
panic("httphi: method must be uppercase in registration " + optMethodAndPath)
|
||||
}
|
||||
method = MethodFrom(methodOrURL)
|
||||
} else {
|
||||
url = methodOrURL
|
||||
}
|
||||
if len(url) == 0 || url[0] != '/' {
|
||||
panic("httphi: path must begin with '/' in registration " + optMethodAndPath)
|
||||
}
|
||||
for _, endpoint := range sm._handlers {
|
||||
if endpoint.method == method && endpoint.path == url {
|
||||
if method == MethUnknown {
|
||||
// Two extension methods are both MethUnknown, so the second is
|
||||
// unreachable. Register one and branch in the handler, see
|
||||
// [MuxSlice.LookupHandler].
|
||||
panic("httphi: extension method already registered on path in " + optMethodAndPath)
|
||||
}
|
||||
panic("httphi: duplicate registration " + optMethodAndPath)
|
||||
}
|
||||
}
|
||||
v := internal.SliceReclaim(&sm._handlers)
|
||||
v.pathVals = countPathValues(url)
|
||||
v.spec = patternSpecificity(url)
|
||||
v.method = method
|
||||
v.path = url
|
||||
v.handler = handler
|
||||
}
|
||||
|
||||
// patternSpecificity scores how tightly pattern pins a path, letting
|
||||
// [MuxSlice.LookupHandler] prefer the most specific match over the first one
|
||||
// registered. A literal segment pins harder than a wildcard segment, and a
|
||||
// pattern left open at the end ("/", "/files/", "/{p...}") pins less than one
|
||||
// spent on the whole path, so "/cnt" outscores "/" and "/users/me" outscores
|
||||
// "/users/{id}". Scoring at registration keeps lookup to an integer compare.
|
||||
//
|
||||
// The score is a total order over patterns, which the subset relation is not:
|
||||
// neither of "/a/{x}/c" and "/a/b/{y}" is more specific than the other, and they
|
||||
// tie here where http.ServeMux rejects the pair as conflicting. A tie is settled
|
||||
// by registration order rather than by a panic.
|
||||
func patternSpecificity(pattern string) (spec int) {
|
||||
if len(pattern) == 0 || pattern[0] != '/' {
|
||||
return 0
|
||||
}
|
||||
pattern = pattern[1:]
|
||||
for {
|
||||
if len(pattern) == 0 {
|
||||
return spec // Nothing after a slash: an anonymous "{...}" taking the rest.
|
||||
}
|
||||
segment, rest, more := strings.Cut(pattern, "/")
|
||||
name, isMulti, isWildcard := pathWildcard(segment)
|
||||
switch {
|
||||
case isWildcard && name == "$":
|
||||
return spec + 1 // Ends the path, so nothing is left open.
|
||||
case isWildcard && isMulti:
|
||||
return spec // Takes the remainder, pinning nothing more.
|
||||
case isWildcard:
|
||||
spec++
|
||||
default:
|
||||
spec += 2
|
||||
}
|
||||
if !more {
|
||||
return spec + 1 // Spent on the last segment: the pattern is exact.
|
||||
}
|
||||
pattern = rest
|
||||
}
|
||||
}
|
||||
|
||||
// countPathValues is how many values pattern can bind, which is what sizes the
|
||||
// slice [SetPathValues] writes into. Only a named wildcard segment binds: "{$}"
|
||||
// marks the path's end, an anonymous "{...}" has no name to bind under, and a
|
||||
// brace inside a literal segment is not a wildcard at all.
|
||||
func countPathValues(pattern string) (n int) {
|
||||
if len(pattern) == 0 || pattern[0] != '/' {
|
||||
return 0
|
||||
}
|
||||
pattern = pattern[1:]
|
||||
for len(pattern) > 0 {
|
||||
segment, rest, more := strings.Cut(pattern, "/")
|
||||
if name, _, ok := pathWildcard(segment); ok && name != "" && name != "$" {
|
||||
n++
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
pattern = rest
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// isWildcardPattern reports whether pattern must go through [SetPathValues]
|
||||
// rather than a literal comparison. Distinct from the value count: "{$}" and a
|
||||
// trailing slash match by walking segments while binding nothing.
|
||||
func isWildcardPattern(pattern string) bool {
|
||||
return strings.IndexByte(pattern, '{') >= 0 || strings.HasSuffix(pattern, "/")
|
||||
}
|
||||
|
||||
// hasLowerASCII reports whether s carries an ASCII lowercase letter, which a
|
||||
// method token registered by mistake ("Get") does and a legal extension method
|
||||
// ("PROPFIND") does not.
|
||||
func hasLowerASCII(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] >= 'a' && s[i] <= 'z' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Method is a HTTP request method, parsed by [MethodFrom].
|
||||
type Method uint8
|
||||
|
||||
|
||||
+301
-8
@@ -54,7 +54,7 @@ func TestSetPathValues(t *testing.T) {
|
||||
{pattern: "/a/{x}/b", path: "/a//b", match: false},
|
||||
} {
|
||||
t.Run(test.pattern+"__"+test.path, func(t *testing.T) {
|
||||
vals := make([]pathValue, 8)
|
||||
vals := make([]PathValue, 8)
|
||||
match, tooShort := SetPathValues(vals, test.pattern, []byte(test.path))
|
||||
if tooShort {
|
||||
t.Fatal("8 slots must be enough for these patterns")
|
||||
@@ -76,7 +76,7 @@ func TestSetPathValues(t *testing.T) {
|
||||
// exchange owns that memory and a copy would allocate per request.
|
||||
func TestSetPathValuesAliasesRequestBuffer(t *testing.T) {
|
||||
path := []byte("/users/42/edit")
|
||||
vals := make([]pathValue, 4)
|
||||
vals := make([]PathValue, 4)
|
||||
match, _ := SetPathValues(vals, "/users/{id}/edit", path)
|
||||
if !match {
|
||||
t.Fatal("want match")
|
||||
@@ -94,7 +94,7 @@ func TestSetPathValuesAliasesRequestBuffer(t *testing.T) {
|
||||
// A destination too small to hold every wildcard must say so rather than bind a
|
||||
// partial set or write out of range.
|
||||
func TestSetPathValuesSliceTooShort(t *testing.T) {
|
||||
match, tooShort := SetPathValues(make([]pathValue, 1), "/{a}/{b}", []byte("/x/y"))
|
||||
match, tooShort := SetPathValues(make([]PathValue, 1), "/{a}/{b}", []byte("/x/y"))
|
||||
if !tooShort {
|
||||
t.Error("want pathValSliceTooShort for 2 wildcards in 1 slot")
|
||||
}
|
||||
@@ -112,11 +112,11 @@ func TestSetPathValuesSliceTooShort(t *testing.T) {
|
||||
// segment, so "/users/x%2Fy" binds id="x/y" there and id="x%2Fy" here. Matching
|
||||
// agrees either way; only the bound bytes differ.
|
||||
func TestSetPathValuesEscaping(t *testing.T) {
|
||||
vals := make([]pathValue, 4)
|
||||
vals := make([]PathValue, 4)
|
||||
if match, _ := SetPathValues(vals, "/a%2Fb/{x}", []byte("/a%2Fb/v")); !match {
|
||||
t.Error("want literal escape in pattern to match the same bytes in path")
|
||||
}
|
||||
vals = make([]pathValue, 4)
|
||||
vals = make([]PathValue, 4)
|
||||
match, _ := SetPathValues(vals, "/users/{id}", []byte("/users/x%2Fy"))
|
||||
if !match {
|
||||
t.Fatal("want match")
|
||||
@@ -128,7 +128,7 @@ func TestSetPathValuesEscaping(t *testing.T) {
|
||||
|
||||
// renderPathValues joins the bound pairs for comparison, stopping at the first
|
||||
// unused slot.
|
||||
func renderPathValues(vals []pathValue) string {
|
||||
func renderPathValues(vals []PathValue) string {
|
||||
var sb strings.Builder
|
||||
for _, v := range vals {
|
||||
if v.Key == "" {
|
||||
@@ -147,7 +147,7 @@ func renderPathValues(vals []pathValue) string {
|
||||
// Matching a request must not allocate: keys alias the mux's pattern and values
|
||||
// alias the request buffer, so nothing is copied per request.
|
||||
func TestSetPathValuesNoAlloc(t *testing.T) {
|
||||
vals := make([]pathValue, 8)
|
||||
vals := make([]PathValue, 8)
|
||||
path := []byte("/b/bk/o/a/b/c")
|
||||
allocs := testing.AllocsPerRun(100, func() {
|
||||
SetPathValues(vals, "/b/{bucket}/o/{obj...}", path)
|
||||
@@ -164,7 +164,11 @@ type pathValueMux struct {
|
||||
handler HandlerFunc
|
||||
}
|
||||
|
||||
func (m *pathValueMux) LookupHandler(method Method, path []byte, dst []pathValue) (string, HandlerFunc) {
|
||||
func (m *pathValueMux) MaxPathValues() int {
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *pathValueMux) LookupHandler(method Method, path []byte, dst []PathValue) (string, HandlerFunc) {
|
||||
if ok, _ := SetPathValues(dst, m.pattern, path); ok {
|
||||
return m.pattern, m.handler
|
||||
}
|
||||
@@ -220,3 +224,292 @@ func TestExchangePathValueClearedBetweenRequests(t *testing.T) {
|
||||
t.Errorf("want no path value on a literal route, got id=%q from the previous request", leaked)
|
||||
}
|
||||
}
|
||||
|
||||
// A lookup tries each endpoint in turn and [SetPathValues] binds as it walks, so
|
||||
// a pattern that binds values and then fails must not leave them behind for the
|
||||
// pattern that does match: a handler would read a wildcard no matched pattern has.
|
||||
func TestMuxSliceNoStaleBindingsAcrossCandidates(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(2)
|
||||
sm.Handle("/a/{x}/{y}/z", func(ex *Exchange) { t.Error("non-matching handler ran") })
|
||||
var gotP, gotX, gotY string
|
||||
sm.Handle("/a/{p}/b", func(ex *Exchange) {
|
||||
gotP = string(ex.PathValue("p"))
|
||||
gotX = string(ex.PathValue("x"))
|
||||
gotY = string(ex.PathValue("y"))
|
||||
ex.WriteHeader(200)
|
||||
})
|
||||
|
||||
exch := new(Exchange)
|
||||
exch.Configure(ExchangeConfig{
|
||||
RawBuf: make([]byte, 2048), RequestBufferLim: 1024,
|
||||
NumHeaderKVCap: defaultNumHeaderKVCap, MaxPathValues: sm.MaxPathValues(),
|
||||
})
|
||||
conn := newConn("GET /a/1/b HTTP/1.1\r\nHost: h\r\n\r\n")
|
||||
conn.Hangup()
|
||||
if !exch.Acquire(conn) {
|
||||
t.Fatal("fresh exchange failed to acquire")
|
||||
}
|
||||
if err := Handle(exch, &sm, nopBackoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotP != "1" {
|
||||
t.Errorf("want p=1 from the matched pattern, got %q", gotP)
|
||||
}
|
||||
if gotX != "" || gotY != "" {
|
||||
t.Errorf("want no x/y from the failed candidate, got x=%q y=%q", gotX, gotY)
|
||||
}
|
||||
}
|
||||
|
||||
// A pattern ending in '/' carries no brace but is still a wildcard: the trailing
|
||||
// slash is an anonymous "{...}", see [SetPathValues]. MuxSlice must route it
|
||||
// through the same matcher rather than comparing the path literally.
|
||||
func TestMuxSliceTrailingSlashPattern(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
pattern string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{pattern: "/files/", path: "/files/a/b", want: true},
|
||||
{pattern: "/files/", path: "/files/", want: true},
|
||||
{pattern: "/files/", path: "/files", want: false},
|
||||
{pattern: "/files/", path: "/other/a", want: false},
|
||||
{pattern: "/", path: "/anything/at/all", want: true},
|
||||
{pattern: "/", path: "/", want: true},
|
||||
// Without the trailing slash a pattern stays literal.
|
||||
{pattern: "/files", path: "/files/a", want: false},
|
||||
{pattern: "/files", path: "/files", want: true},
|
||||
} {
|
||||
t.Run(test.pattern+"__"+test.path, func(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
var served bool
|
||||
sm.Handle(test.pattern, func(ex *Exchange) { served = true; ex.WriteHeader(200) })
|
||||
// MuxSlice must agree with the matcher it delegates to.
|
||||
if ok, _ := SetPathValues(nil, test.pattern, []byte(test.path)); ok != test.want {
|
||||
t.Fatalf("SetPathValues disagrees with the table: got %v", ok)
|
||||
}
|
||||
exch := new(Exchange)
|
||||
exch.Configure(ExchangeConfig{
|
||||
RawBuf: make([]byte, 2048), RequestBufferLim: 1024,
|
||||
NumHeaderKVCap: defaultNumHeaderKVCap, MaxPathValues: sm.MaxPathValues(),
|
||||
})
|
||||
conn := newConn("GET " + test.path + " HTTP/1.1\r\nHost: h\r\n\r\n")
|
||||
conn.Hangup()
|
||||
if !exch.Acquire(conn) {
|
||||
t.Fatal("fresh exchange failed to acquire")
|
||||
}
|
||||
if err := Handle(exch, &sm, nopBackoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if served != test.want {
|
||||
t.Errorf("want served=%v, got %v", test.want, served)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed registration is a programming error, and one that otherwise costs
|
||||
// a permanent silent 404 at runtime: "Get /x" parses to MethUnknown, which no
|
||||
// GET request ever matches but every extension-method request does. Fail at
|
||||
// registration, where the stack points at the offending line.
|
||||
func TestMuxSliceHandlePanicsOnBadRegistration(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
reg string
|
||||
}{
|
||||
{name: "lowercase method", reg: "Get /x"},
|
||||
{name: "all lower method", reg: "get /x"},
|
||||
{name: "mixed case method", reg: "pOsT /x"},
|
||||
{name: "no leading slash", reg: "GET x"},
|
||||
{name: "bare path no slash", reg: "x"},
|
||||
{name: "empty path after method", reg: "GET "},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("want panic registering %q", test.reg)
|
||||
}
|
||||
}()
|
||||
sm.Handle(test.reg, func(ex *Exchange) {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An exact duplicate is unreachable code: the first registration always wins.
|
||||
func TestMuxSliceHandlePanicsOnDuplicate(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(2)
|
||||
sm.Handle("GET /x", func(ex *Exchange) {})
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("want panic registering the same method and path twice")
|
||||
}
|
||||
}()
|
||||
sm.Handle("GET /x", func(ex *Exchange) {})
|
||||
}
|
||||
|
||||
// Extension methods are legal and uppercase, so they must still register: only
|
||||
// the case-mangled forms are rejected.
|
||||
func TestMuxSliceHandleAllowsExtensionMethod(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(2)
|
||||
sm.Handle("PROPFIND /dav", func(ex *Exchange) {})
|
||||
sm.Handle("/any-method", func(ex *Exchange) {}) // Bare path matches any method.
|
||||
if sm.MaxPathValues() != 0 {
|
||||
t.Errorf("want 0 path values, got %d", sm.MaxPathValues())
|
||||
}
|
||||
}
|
||||
|
||||
// A catch-all registered before the endpoints it sits above must not swallow
|
||||
// them. "/" is a wildcard pattern: its trailing slash is an anonymous "{...}",
|
||||
// so a purely first-match-wins scan hands every request to it and the specific
|
||||
// registrations below become dead code, answered with the root page instead of
|
||||
// their own body. Registering the site root first is the ordinary way to write
|
||||
// a mux, so the more specific pattern has to win regardless of order, as in
|
||||
// http.ServeMux.
|
||||
func TestMuxSliceSpecificPatternBeatsEarlierCatchAll(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
register []string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "root registered first",
|
||||
register: []string{"/", "/hello", "/cnt", "/6"},
|
||||
path: "/cnt",
|
||||
want: "/cnt",
|
||||
}, {
|
||||
name: "root registered last",
|
||||
register: []string{"/hello", "/cnt", "/6", "/"},
|
||||
path: "/cnt",
|
||||
want: "/cnt",
|
||||
}, {
|
||||
name: "root still serves the root path",
|
||||
register: []string{"/", "/cnt"},
|
||||
path: "/",
|
||||
want: "/",
|
||||
}, {
|
||||
name: "root still catches the unregistered",
|
||||
register: []string{"/", "/cnt"},
|
||||
path: "/nowhere",
|
||||
want: "/",
|
||||
}, {
|
||||
name: "subtree wildcard loses to its own literal",
|
||||
register: []string{"/files/", "/files/index"},
|
||||
path: "/files/index",
|
||||
want: "/files/index",
|
||||
}, {
|
||||
name: "subtree wildcard keeps the rest",
|
||||
register: []string{"/files/", "/files/index"},
|
||||
path: "/files/a/b",
|
||||
want: "/files/",
|
||||
}, {
|
||||
name: "longer literal prefix wins over shorter subtree",
|
||||
register: []string{"/", "/files/", "/files/a/b"},
|
||||
path: "/files/a/b",
|
||||
want: "/files/a/b",
|
||||
}, {
|
||||
name: "named wildcard loses to the literal it covers",
|
||||
register: []string{"/users/{id}", "/users/me"},
|
||||
path: "/users/me",
|
||||
want: "/users/me",
|
||||
}, {
|
||||
name: "named wildcard keeps everything else",
|
||||
register: []string{"/users/{id}", "/users/me"},
|
||||
path: "/users/42",
|
||||
want: "/users/{id}",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(len(test.register))
|
||||
for _, pattern := range test.register {
|
||||
sm.Handle(pattern, func(ex *Exchange) { ex.WriteHeader(200) })
|
||||
}
|
||||
pathVals := make([]PathValue, max(sm.MaxPathValues(), 0))
|
||||
got, handler := sm.LookupHandler(MethGet, []byte(test.path), pathVals)
|
||||
if handler == nil {
|
||||
t.Fatalf("%s matched no handler, want %q", test.path, test.want)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Errorf("%s matched pattern %q, want %q", test.path, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// pathVals sizes the slice SetPathValues writes into, so it must count exactly
|
||||
// the wildcards that bind. Counting braces over-reports: "{$}" marks the path's
|
||||
// end, an anonymous "{...}" has no name, and a brace inside a literal segment is
|
||||
// not a wildcard at all. Each of those binds nothing.
|
||||
func TestMuxSliceMaxPathValuesCountsOnlyBindingWildcards(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
pattern string
|
||||
want int
|
||||
}{
|
||||
{pattern: "/health", want: 0},
|
||||
{pattern: "/", want: 0},
|
||||
{pattern: "/files/", want: 0}, // Anonymous trailing wildcard.
|
||||
{pattern: "/{$}", want: 0}, // End-of-path marker.
|
||||
{pattern: "/a/{$}", want: 0}, //
|
||||
{pattern: "/b_{bucket}", want: 0}, // Literal: brace not a whole segment.
|
||||
{pattern: "/{...}", want: 0}, // Multi wildcard with no name.
|
||||
{pattern: "/users/{id}", want: 1}, //
|
||||
{pattern: "/files/{p...}", want: 1}, //
|
||||
{pattern: "/{a}/{b}", want: 2}, //
|
||||
{pattern: "/b/{bucket}/o/{obj...}", want: 2},
|
||||
} {
|
||||
t.Run(test.pattern, func(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
sm.Handle(test.pattern, func(ex *Exchange) {})
|
||||
if got := sm.MaxPathValues(); got != test.want {
|
||||
t.Errorf("want %d path values, got %d", test.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sizing and routing are different questions: "{$}" binds no values yet still
|
||||
// needs the matcher, so an exact pathVals count must not send it to the literal
|
||||
// comparison instead.
|
||||
func TestMuxSliceZeroValueWildcardStillMatches(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
pattern string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{pattern: "/{$}", path: "/", want: true},
|
||||
{pattern: "/{$}", path: "/x", want: false},
|
||||
{pattern: "/a/{$}", path: "/a/", want: true},
|
||||
{pattern: "/a/{$}", path: "/a/b", want: false},
|
||||
{pattern: "/{...}", path: "/any/thing", want: true},
|
||||
} {
|
||||
t.Run(test.pattern+"__"+test.path, func(t *testing.T) {
|
||||
var sm MuxSlice
|
||||
sm.Reset(1)
|
||||
var served bool
|
||||
sm.Handle(test.pattern, func(ex *Exchange) { served = true; ex.WriteHeader(200) })
|
||||
exch := new(Exchange)
|
||||
exch.Configure(ExchangeConfig{
|
||||
RawBuf: make([]byte, 2048), RequestBufferLim: 1024,
|
||||
NumHeaderKVCap: defaultNumHeaderKVCap, MaxPathValues: sm.MaxPathValues(),
|
||||
})
|
||||
conn := newConn("GET " + test.path + " HTTP/1.1\r\nHost: h\r\n\r\n")
|
||||
conn.Hangup()
|
||||
if !exch.Acquire(conn) {
|
||||
t.Fatal("acquire")
|
||||
}
|
||||
if err := Handle(exch, &sm, nopBackoff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if served != test.want {
|
||||
t.Errorf("want served=%v, got %v", test.want, served)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+25
-23
@@ -70,34 +70,32 @@ type job struct {
|
||||
}
|
||||
|
||||
// RouterConfig configures a [Router]. See [Router.Configure].
|
||||
//
|
||||
// Each field below opens with Required, Conditional or Optional followed by the
|
||||
// constraint in brackets, that being what [RouterConfig.Validate] rejects on.
|
||||
type RouterConfig struct {
|
||||
// FixedNumGoroutines must be set to either -1 (freely allocate new goroutines) or to the number of goroutines
|
||||
// to spawn on [Router.Configure] being called.
|
||||
// Required [-1 or >0] number of goroutines to spawn on [Router.Configure],
|
||||
// -1 meaning allocate them freely per connection instead.
|
||||
FixedNumGoroutines int
|
||||
// RequestHeaderBufferSize determines the buffer allocated
|
||||
// for processing request HTTP headers including request-target (URI), protocol and key/value pairs.
|
||||
// Required [>=32, sum with ResponseHeaderMinBufferSize <=65535] buffer for the
|
||||
// request header: request-target (URI), protocol and key/value pairs.
|
||||
RequestHeaderBufferSize int
|
||||
// ResponseHeaderMinBufferSize determines buffer allocated for processing response headers.
|
||||
// Response buffer will reuse unused request memory so this is not a strict limit.
|
||||
// "HTTP/1.1 200 OK\r\n" does not count towards this memory, only actual Headers key/value pairs use this memory.
|
||||
// After memory is fully consumed [Exchange.StageHeader] will not append more headers.
|
||||
// Required [>=2, <=65535] buffer for response headers. Reuses unused request
|
||||
// memory so it is not a strict limit, and the status line does not count
|
||||
// towards it. Once consumed [Exchange.StageHeader] appends no more fields.
|
||||
ResponseHeaderMinBufferSize int
|
||||
// Number of request header key/value pairs to parse before failing and returning [StatusRequestHeaderFieldsTooLarge].
|
||||
// Required [>0] request header key/value pairs to parse before failing with
|
||||
// [StatusRequestHeaderFieldsTooLarge].
|
||||
RequestNumHeaderKVCap int
|
||||
// Sets maximum number of PathValue pairs that can be set on an exchange. Accessed via [Exchange.PathValue].
|
||||
MaxPathValues int
|
||||
|
||||
// NormalizeOutgoingKeys normalizes response header field keys as they are
|
||||
// Optional [any] normalization of response header field keys as they are
|
||||
// staged, i.e: "content-type" becomes "Content-Type".
|
||||
NormalizeOutgoingKeys bool
|
||||
// MaxAwaitingConns is the depth of the queue connections wait in for a free
|
||||
// goroutine. [Router.Handle] drops connections once it is full. Required and
|
||||
// must be non-zero when running a fixed number of goroutines, unused otherwise.
|
||||
MaxAwaitingConns int
|
||||
|
||||
// Mux resolves each request's method and path to the handler serving it. Required.
|
||||
// Required [non-nil] resolver of each request's method and path to the handler
|
||||
// serving it. Routes must be registered before Configure, see [Mux.MaxPathValues].
|
||||
Mux Mux
|
||||
// Logger receives failed exchanges. Optional, nil disables logging.
|
||||
// Optional [nil disables] sink for failed exchanges.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
@@ -120,7 +118,6 @@ func (cfg RouterConfig) Validate() error {
|
||||
switch {
|
||||
case cfg.Mux == nil,
|
||||
!workerMode && cfg.FixedNumGoroutines != -1,
|
||||
workerMode && cfg.MaxAwaitingConns <= 0,
|
||||
cfg.RequestNumHeaderKVCap <= 0,
|
||||
cfg.RequestHeaderBufferSize < minRequestHeaderBuffer,
|
||||
cfg.ResponseHeaderMinBufferSize < minResponseHeaderBuffer,
|
||||
@@ -186,7 +183,11 @@ func (r *Router) Configure(cfg RouterConfig) error {
|
||||
r.respBuf = cfg.ResponseHeaderMinBufferSize
|
||||
r.mux = cfg.Mux
|
||||
r.log = cfg.Logger
|
||||
r.maxPathValues = cfg.MaxPathValues
|
||||
maxPathValues := cfg.Mux.MaxPathValues()
|
||||
if maxPathValues < 0 {
|
||||
return errors.New("Mux paths must be registered before configuring Router")
|
||||
}
|
||||
r.maxPathValues = maxPathValues
|
||||
r.normalizeKeys = cfg.NormalizeOutgoingKeys
|
||||
// Freelist entries were sized by the outgoing configuration: recycling one
|
||||
// would serve a request with buffer limits cfg never asked for.
|
||||
@@ -197,7 +198,7 @@ func (r *Router) Configure(cfg RouterConfig) error {
|
||||
return nil
|
||||
}
|
||||
if workerMode {
|
||||
jobqueue := make(chan job, cfg.MaxAwaitingConns)
|
||||
jobqueue := make(chan job, cfg.FixedNumGoroutines)
|
||||
if gen > 1 {
|
||||
// Exchange buffers below are reused: the previous generation must be
|
||||
// done serving before they may be handed to the new one.
|
||||
@@ -210,6 +211,7 @@ func (r *Router) Configure(cfg RouterConfig) error {
|
||||
r.exchs = r.exchs[:numgoro]
|
||||
rawBuflen := cfg.RequestHeaderBufferSize + cfg.ResponseHeaderMinBufferSize
|
||||
internal.SliceReuse(&r.globbuf, numgoro*rawBuflen)
|
||||
|
||||
for i := range numgoro {
|
||||
// TODO exchange buffer alloc
|
||||
goff := i * rawBuflen
|
||||
@@ -220,7 +222,7 @@ func (r *Router) Configure(cfg RouterConfig) error {
|
||||
NumHeaderKVCap: cfg.RequestNumHeaderKVCap,
|
||||
NormalizeOutgoingKeys: cfg.NormalizeOutgoingKeys,
|
||||
NoRequestBufferGrowth: true, // Hard memory limit.
|
||||
MaxPathValues: cfg.MaxPathValues,
|
||||
MaxPathValues: maxPathValues,
|
||||
})
|
||||
go r.goroWorker(gen, jobqueue, cfg.Mux)
|
||||
}
|
||||
@@ -306,7 +308,7 @@ func (r *Router) goroWorker(gen uint32, queue chan job, mux Mux) {
|
||||
for job := range queue {
|
||||
exch := job.exch
|
||||
if exch == nil {
|
||||
panic("httplo: unreachable nil job")
|
||||
panic("httphi: unreachable nil job")
|
||||
} else if gen != r.gen.Load() {
|
||||
// Not released with freeExch since generation torn down,
|
||||
// new buffer may have been allocated for Exchanges.
|
||||
|
||||
@@ -196,8 +196,10 @@ func TestRouterRequestVisibleToHandler(t *testing.T) {
|
||||
router Router
|
||||
)
|
||||
var gotMethod, gotURI, gotHost string
|
||||
var gotMethodEnum Method
|
||||
sm.Handle("GET /index.html", func(ex *Exchange) {
|
||||
gotMethod = string(ex.RequestMethod())
|
||||
gotMethod = string(ex.RequestMethodRaw())
|
||||
gotMethodEnum = ex.RequestMethod()
|
||||
gotURI = string(ex.RequestTarget())
|
||||
gotHost = string(ex.RequestHeader("Host"))
|
||||
ex.WriteHeader(200)
|
||||
@@ -213,6 +215,9 @@ func TestRouterRequestVisibleToHandler(t *testing.T) {
|
||||
if gotMethod != "GET" {
|
||||
t.Errorf("want method %q, got %q", "GET", gotMethod)
|
||||
}
|
||||
if gotMethodEnum != MethGet {
|
||||
t.Errorf("want method enum %q, got %q", MethGet, gotMethodEnum)
|
||||
}
|
||||
if gotURI != "/index.html" {
|
||||
t.Errorf("want URI %q, got %q", "/index.html", gotURI)
|
||||
}
|
||||
@@ -242,7 +247,9 @@ func TestRouterMux(t *testing.T) {
|
||||
sm MuxSlice
|
||||
router Router
|
||||
)
|
||||
sm.Handle("GET /", staticPage(t, "root"))
|
||||
// "/{$}" is the root alone: a bare "/" is a catch-all and would serve
|
||||
// "root" for /page and /nowhere as well, see [SetPathValues].
|
||||
sm.Handle("GET /{$}", staticPage(t, "root"))
|
||||
sm.Handle("GET /page", staticPage(t, "page"))
|
||||
sm.Handle("/any", staticPage(t, "any")) // No method: matches any.
|
||||
configSynchronousRouter(t, &router, bufferSize, &sm)
|
||||
@@ -383,7 +390,6 @@ func TestRouterHandleAfterTeardown(t *testing.T) {
|
||||
sm.Handle("GET /", staticPage(t, "ok"))
|
||||
err := router.Configure(RouterConfig{
|
||||
FixedNumGoroutines: 2,
|
||||
MaxAwaitingConns: 4,
|
||||
Mux: &sm,
|
||||
RequestHeaderBufferSize: 512,
|
||||
RequestNumHeaderKVCap: 16,
|
||||
@@ -416,7 +422,6 @@ func TestRouterTeardownReleasesQueuedConns(t *testing.T) {
|
||||
sm.Handle("GET /", staticPage(t, "ok"))
|
||||
cfg := RouterConfig{
|
||||
FixedNumGoroutines: numGoro,
|
||||
MaxAwaitingConns: 4,
|
||||
Mux: &sm,
|
||||
RequestHeaderBufferSize: 512,
|
||||
RequestNumHeaderKVCap: 16,
|
||||
@@ -463,7 +468,6 @@ func TestRouterConfigureDuringWorkerHandle(t *testing.T) {
|
||||
sm.Handle("GET /", staticPage(t, "ok"))
|
||||
cfg := RouterConfig{
|
||||
FixedNumGoroutines: 2,
|
||||
MaxAwaitingConns: 4,
|
||||
Mux: &sm,
|
||||
RequestHeaderBufferSize: 512,
|
||||
RequestNumHeaderKVCap: 16,
|
||||
|
||||
Reference in New Issue
Block a user